@aws-blocks/core 0.1.13 → 0.1.17

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.
Files changed (53) hide show
  1. package/README.md +180 -17
  2. package/dist/cors.d.ts +27 -1
  3. package/dist/cors.d.ts.map +1 -1
  4. package/dist/cors.js +55 -2
  5. package/dist/cors.test.js +81 -2
  6. package/dist/errors.test.js +26 -1
  7. package/dist/hosting.d.ts.map +1 -1
  8. package/dist/hosting.js +26 -1
  9. package/dist/hosting.test.js +73 -0
  10. package/dist/lambda-handler.d.ts.map +1 -1
  11. package/dist/lambda-handler.js +4 -17
  12. package/dist/lambda-handler.test.js +59 -2
  13. package/dist/rpc.test.js +77 -1
  14. package/dist/scripts/console.d.ts.map +1 -1
  15. package/dist/scripts/console.js +30 -2
  16. package/dist/scripts/deploy-stream.d.ts +181 -0
  17. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  18. package/dist/scripts/deploy-stream.js +332 -0
  19. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  20. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  21. package/dist/scripts/deploy-stream.test.js +845 -0
  22. package/dist/scripts/deploy.d.ts.map +1 -1
  23. package/dist/scripts/deploy.js +16 -9
  24. package/dist/scripts/dev-server-cors.test.js +19 -1
  25. package/dist/scripts/dev-server-rpc.test.js +50 -0
  26. package/dist/scripts/dev-server.d.ts +8 -0
  27. package/dist/scripts/dev-server.d.ts.map +1 -1
  28. package/dist/scripts/dev-server.js +35 -8
  29. package/dist/scripts/sandbox.js +1 -1
  30. package/dist/telemetry/client.js +4 -4
  31. package/dist/telemetry/telemetry-send-worker.js +4 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +10 -1
  35. package/src/cors.test.ts +96 -2
  36. package/src/cors.ts +59 -2
  37. package/src/errors.test.ts +29 -1
  38. package/src/hosting.test.ts +107 -0
  39. package/src/hosting.ts +27 -1
  40. package/src/lambda-handler.test.ts +71 -2
  41. package/src/lambda-handler.ts +4 -20
  42. package/src/rpc.test.ts +96 -1
  43. package/src/scripts/console.ts +29 -2
  44. package/src/scripts/deploy-stream.test.ts +1035 -0
  45. package/src/scripts/deploy-stream.ts +475 -0
  46. package/src/scripts/deploy.ts +18 -11
  47. package/src/scripts/dev-server-cors.test.ts +26 -1
  48. package/src/scripts/dev-server-rpc.test.ts +54 -0
  49. package/src/scripts/dev-server.ts +38 -8
  50. package/src/scripts/sandbox.ts +1 -1
  51. package/src/telemetry/client.ts +4 -4
  52. package/src/telemetry/telemetry-send-worker.ts +5 -0
  53. package/src/version.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,iBA0FlD"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,iBAiGlD"}
@@ -8,7 +8,7 @@ import { ensureSecrets, loadProductionEnv } from './ensure-secrets.js';
8
8
  import { applyExternalMigrations } from './external-migrations-step.js';
9
9
  import { trackCommand } from '../telemetry/trackCommand.js';
10
10
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
11
- import { runSync } from './run-command.js';
11
+ import { runStreaming, buildCdkDeployArgs } from './deploy-stream.js';
12
12
  export async function deploy(options) {
13
13
  return trackCommand('deploy', async () => {
14
14
  console.log('šŸ—ļø Preparing deployment...');
@@ -44,14 +44,14 @@ export async function deploy(options) {
44
44
  console.log(' (This may take a few minutes on first deploy)');
45
45
  console.log(' - Backend API (Lambda + API Gateway)');
46
46
  console.log(' - Frontend hosting (S3 + CloudFront)');
47
+ console.log(' Streaming CloudFormation events below; the deploy keeps running if this');
48
+ console.log(' process is backgrounded (press Ctrl-C, or send SIGTERM twice, to abort).');
47
49
  try {
48
- runSync("npx", [
49
- "cdk", "deploy",
50
- "--require-approval", "never",
51
- "--outputs-file", ".blocks-sandbox/outputs.json",
52
- "--context", `projectRoot=${options.projectRoot}`,
53
- ], {
54
- stdio: 'inherit',
50
+ await runStreaming("npx", buildCdkDeployArgs({
51
+ projectRoot: options.projectRoot,
52
+ outputsFile: '.blocks-sandbox/outputs.json',
53
+ }), {
54
+ label: 'cdk deploy',
55
55
  cwd: options.projectRoot,
56
56
  env: {
57
57
  ...process.env,
@@ -61,7 +61,14 @@ export async function deploy(options) {
61
61
  });
62
62
  }
63
63
  catch (error) {
64
- console.error('\nāŒ Deployment failed.');
64
+ // Terminal verdict on stdout: a caller that only captures stdout (the case
65
+ // that produced phantom failures) must still be able to tell a failed
66
+ // deploy from a killed process. This banner deliberately moved off stderr,
67
+ // so grepping stderr for this exact string no longer matches — the failure
68
+ // *reason* is still there. The CDK CLI keeps error-level output on stderr
69
+ // even under `--ci`, and the entrypoint prints the error itself with
70
+ // `console.error(error)`.
71
+ console.log('\nāŒ Deployment failed.');
65
72
  throw error;
66
73
  }
67
74
  const outputs = JSON.parse(readFileSync(join(options.projectRoot, '.blocks-sandbox', 'outputs.json'), 'utf-8'));
@@ -2,7 +2,8 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { resolveDevCorsOrigin, LOCALHOST_PATTERN } from './dev-server.js';
5
+ import { resolveDevCorsOrigin, LOCALHOST_PATTERN, buildDevCorsHeaders } from './dev-server.js';
6
+ import { CORS_MAX_AGE } from '../cors.js';
6
7
  describe('resolveDevCorsOrigin — dev server CORS', () => {
7
8
  it('reflects localhost origin back as-is', () => {
8
9
  assert.strictEqual(resolveDevCorsOrigin('http://localhost:3000'), 'http://localhost:3000');
@@ -21,3 +22,20 @@ describe('resolveDevCorsOrigin — dev server CORS', () => {
21
22
  assert.strictEqual(resolveDevCorsOrigin('http://localhost.evil.com'), 'http://localhost:3000');
22
23
  });
23
24
  });
25
+ describe('buildDevCorsHeaders — dev server header set', () => {
26
+ it('shares one Max-Age value with the Lambda path so the two cannot drift', () => {
27
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Access-Control-Max-Age'], CORS_MAX_AGE);
28
+ });
29
+ it('sets Vary: Origin because Allow-Origin is reflected per request', () => {
30
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Vary'], 'Origin');
31
+ assert.strictEqual(buildDevCorsHeaders('https://evil.com')['Vary'], 'Origin');
32
+ });
33
+ it('reflects an allowed localhost origin', () => {
34
+ const headers = buildDevCorsHeaders('http://127.0.0.1:5173');
35
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'http://127.0.0.1:5173');
36
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
37
+ });
38
+ it('does not reflect a non-localhost origin', () => {
39
+ assert.strictEqual(buildDevCorsHeaders('https://evil.com')['Access-Control-Allow-Origin'], 'http://localhost:3000');
40
+ });
41
+ });
@@ -104,4 +104,54 @@ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')
104
104
  assert.ok(stdout.includes('[rpc-ok] testApi.pingVoid'), `Missing verbose success log. stdout: ${stdout}`);
105
105
  assert.ok(!stdout.includes('[rpc-err]'), `Unexpected RPC error log. stdout: ${stdout}`);
106
106
  });
107
+ it('returns a JSON usage hint (not an empty body) for a GET on the API path', async () => {
108
+ const port = await getAvailablePort();
109
+ tempDir = join(tmpdir(), `dev-404-test-${process.pid}-${Date.now()}`);
110
+ mkdirSync(tempDir, { recursive: true });
111
+ writeFileSync(join(tempDir, 'backend.ts'), `
112
+ export const testApi = {
113
+ pingVoid: async () => undefined,
114
+ };
115
+ `);
116
+ writeFileSync(join(tempDir, 'preload.mjs'), `
117
+ if (!process.loadEnvFile) {
118
+ process.loadEnvFile = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
119
+ }
120
+ `);
121
+ writeFileSync(join(tempDir, 'run-dev.ts'), `
122
+ import { startDevServer } from '${join(__dirname, 'dev-server.js').replace(/\\/g, '/')}';
123
+ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')}', port: ${port} });
124
+ `);
125
+ const tsxBin = join(__dirname, '..', '..', '..', '..', 'node_modules', '.bin', 'tsx');
126
+ devProcess = spawn(tsxBin, ['--import', join(tempDir, 'preload.mjs'), join(tempDir, 'run-dev.ts')], {
127
+ cwd: tempDir,
128
+ env: { ...process.env, AWS_BLOCKS_DISABLE_TELEMETRY: '1', BLOCKS_DEV_QUIET: '1' },
129
+ stdio: ['ignore', 'pipe', 'pipe'],
130
+ });
131
+ let stdout = '';
132
+ let stderr = '';
133
+ devProcess.stdout?.on('data', chunk => { stdout += chunk.toString(); });
134
+ devProcess.stderr?.on('data', chunk => { stderr += chunk.toString(); });
135
+ // A GET on the API path (e.g. opening it in a browser) hits the API handler
136
+ // but not the POST branch — the exact case that used to return an empty 404.
137
+ const deadline = Date.now() + 15_000;
138
+ let response;
139
+ let lastError;
140
+ while (!response && Date.now() < deadline) {
141
+ try {
142
+ response = await fetch(`http://127.0.0.1:${port}/aws-blocks/api`, { method: 'GET' });
143
+ }
144
+ catch (error) {
145
+ lastError = error;
146
+ await new Promise(resolve => setTimeout(resolve, 100));
147
+ }
148
+ }
149
+ assert.ok(response, `Dev server did not respond: ${String(lastError)}\nstdout: ${stdout}\nstderr: ${stderr}`);
150
+ assert.strictEqual(response.status, 404);
151
+ assert.strictEqual(response.headers.get('content-type'), 'application/json');
152
+ const payload = await response.json();
153
+ assert.match(payload.error ?? '', /POST/, `404 body should hint at the POST requirement: ${JSON.stringify(payload)}`);
154
+ assert.strictEqual(payload.expected?.method, 'POST');
155
+ assert.strictEqual(payload.expected?.path, '/aws-blocks/api');
156
+ });
107
157
  });
@@ -4,6 +4,14 @@ export declare const LOCALHOST_PATTERN: RegExp;
4
4
  * Reflects back origins matching localhost/127.0.0.1; otherwise returns the fallback.
5
5
  */
6
6
  export declare function resolveDevCorsOrigin(origin: string): string;
7
+ /**
8
+ * Build the CORS response headers the dev server sets on every request.
9
+ *
10
+ * Mirrors the Lambda path's cache posture — same {@link CORS_MAX_AGE} and the
11
+ * same `Vary: Origin` — so the reflected `Access-Control-Allow-Origin` can't be
12
+ * served across origins by a shared cache, and so the two sites can't drift.
13
+ */
14
+ export declare function buildDevCorsHeaders(requestOrigin: string): Record<string, string>;
7
15
  /** Shape of the client runtime config the browser fetches to discover the API URL. */
8
16
  export interface BlocksRuntimeConfig {
9
17
  apiUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,7 @@ import { ApiError } from '../errors.js';
12
12
  import { BLOCKS_RPC_PREFIX, BLOCKS_SANDBOX_PREFIX } from '../constants.js';
13
13
  import { BLOCKS_SANDBOX_DIR } from '../common/constants.js';
14
14
  import { matchRoute, lockRouteRegistry } from '../raw-route.js';
15
+ import { CORS_MAX_AGE } from '../cors.js';
15
16
  import { registerBuiltinRoutes } from '../builtin-routes.js';
16
17
  import { parseRpcRequest, successResponse, errorResponseFromCatch, methodNotFoundResponse, } from '../rpc.js';
17
18
  import { redactToJson } from '../redact.js';
@@ -36,6 +37,23 @@ export const LOCALHOST_PATTERN = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
36
37
  export function resolveDevCorsOrigin(origin) {
37
38
  return LOCALHOST_PATTERN.test(origin) ? origin : 'http://localhost:3000';
38
39
  }
40
+ /**
41
+ * Build the CORS response headers the dev server sets on every request.
42
+ *
43
+ * Mirrors the Lambda path's cache posture — same {@link CORS_MAX_AGE} and the
44
+ * same `Vary: Origin` — so the reflected `Access-Control-Allow-Origin` can't be
45
+ * served across origins by a shared cache, and so the two sites can't drift.
46
+ */
47
+ export function buildDevCorsHeaders(requestOrigin) {
48
+ return {
49
+ 'Access-Control-Allow-Origin': resolveDevCorsOrigin(requestOrigin),
50
+ 'Vary': 'Origin',
51
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
52
+ 'Access-Control-Allow-Headers': 'Content-Type',
53
+ 'Access-Control-Allow-Credentials': 'true',
54
+ 'Access-Control-Max-Age': CORS_MAX_AGE,
55
+ };
56
+ }
39
57
  /**
40
58
  * Build the runtime config the browser fetches at `${BLOCKS_SANDBOX_PREFIX}/config.json`.
41
59
  * In sandbox mode the browser still targets the localhost front door (the dev
@@ -699,12 +717,9 @@ export async function startDevServer(options) {
699
717
  const method = req.method || 'GET';
700
718
  // CORS headers
701
719
  const requestOrigin = req.headers.origin || '';
702
- const allowedOrigin = resolveDevCorsOrigin(requestOrigin);
703
- res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
704
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS');
705
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
706
- res.setHeader('Access-Control-Allow-Credentials', 'true');
707
- res.setHeader('Access-Control-Max-Age', '86400');
720
+ for (const [name, value] of Object.entries(buildDevCorsHeaders(requestOrigin))) {
721
+ res.setHeader(name, value);
722
+ }
708
723
  if (method === 'OPTIONS') {
709
724
  res.writeHead(200);
710
725
  res.end();
@@ -1066,6 +1081,18 @@ function handleApiRequest(req, res, url, method, apis) {
1066
1081
  });
1067
1082
  return;
1068
1083
  }
1069
- res.writeHead(404);
1070
- res.end();
1084
+ // Anything that reaches here didn't match `POST /aws-blocks/api`. Returning a
1085
+ // bare empty 404 leaves a developer poking at the endpoint (e.g. opening it in
1086
+ // a browser, which sends a GET, or trying a REST-style URL) with no feedback.
1087
+ // Reply with a small JSON hint pointing at the one supported shape. When the
1088
+ // path was right but the method wasn't, say so explicitly.
1089
+ const wrongMethodOnApi = url.pathname === BLOCKS_RPC_PREFIX;
1090
+ const message = wrongMethodOnApi
1091
+ ? `The AWS Blocks JSON-RPC API expects POST, not ${method}.`
1092
+ : 'Not found. The AWS Blocks JSON-RPC API is served at POST /aws-blocks/api.';
1093
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1094
+ res.end(JSON.stringify({
1095
+ error: message,
1096
+ expected: { method: 'POST', path: BLOCKS_RPC_PREFIX },
1097
+ }));
1071
1098
  }
@@ -124,7 +124,7 @@ export async function startSandbox(options) {
124
124
  console.log("🌐 Starting local dev server (proxying to AWS)...");
125
125
  console.log(`\n Open http://localhost:${clientPort}\n`);
126
126
  const cdkWatch = spawnCommand("npx", [
127
- "cdk", "watch", "--hotswap",
127
+ "cdk", "watch",
128
128
  `--outputs-file`, `${outDir}/outputs.json`,
129
129
  `--context`, `projectRoot=${process.cwd()}`,
130
130
  `--context`, `sandboxMode=true`,
@@ -192,15 +192,15 @@ export function sendEvent(event) {
192
192
  const workerPath = path.join(dir, 'telemetry-send-worker.js');
193
193
  const child = spawn(process.execPath, [workerPath, endpoint], {
194
194
  detached: true,
195
- stdio: ['pipe', 'ignore', 'ignore'],
195
+ stdio: ['pipe', 'ignore', process.env.NODE_DEBUG?.includes('blocks-telemetry') ? 'inherit' : 'ignore'],
196
196
  // Clear NODE_OPTIONS so inherited flags (e.g. --conditions=cdk) don't interfere
197
197
  env: { ...process.env, NODE_OPTIONS: '' },
198
198
  });
199
- child.stdin.on('error', (err) => { debug('stdin write failed: %s', err.message); });
199
+ child.stdin?.on('error', (err) => { debug('stdin write failed: %s', err.message); });
200
200
  // Payload is small (<1KB JSON) so it fits in the kernel pipe buffer (~64KB)
201
201
  // and survives the parent closing its fd on exit.
202
- child.stdin.write(payload);
203
- child.stdin.end();
202
+ child.stdin?.write(payload);
203
+ child.stdin?.end();
204
204
  child.on('error', (err) => { debug('spawn failed: %s', err.message); });
205
205
  child.unref();
206
206
  debug('spawned telemetry subprocess (pid=%d)', child.pid);
@@ -1,5 +1,9 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ // NOTE: This worker is an intentional near-duplicate of
4
+ // packages/create-blocks-app/src/telemetry-send-worker.ts. It is kept separate
5
+ // because the worker must run with zero project/package imports. Any change
6
+ // here should be mirrored in both files.
3
7
  /**
4
8
  * Telemetry send worker — spawned as a detached subprocess.
5
9
  * Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]).
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.1.13";
1
+ export declare const CORE_VERSION = "0.1.17";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.13';
2
+ export const CORE_VERSION = '0.1.17';
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.13",
3
+ "version": "0.1.17",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
7
+ "directory": "packages/core"
8
+ },
9
+ "homepage": "https://github.com/aws-devtools-labs/aws-blocks/tree/main/packages/core#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/aws-devtools-labs/aws-blocks/issues"
12
+ },
4
13
  "author": "Amazon Web Services",
5
14
  "license": "Apache-2.0",
6
15
  "type": "module",
package/src/cors.test.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { describe, it, beforeEach } from 'node:test';
4
4
  import assert from 'node:assert';
5
5
 
6
- import { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
6
+ import { parseCorsPatterns, _resetCorsPatterns, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
7
7
  import { createLambdaHandler } from './lambda-handler.js';
8
8
  import { clearRouteRegistry } from './raw-route.js';
9
9
 
@@ -59,6 +59,95 @@ describe('parseCorsPatterns', () => {
59
59
  });
60
60
  });
61
61
 
62
+ // ── buildCorsHeaders unit tests ─────────────────────────────────────────────
63
+
64
+ describe('buildCorsHeaders', () => {
65
+ beforeEach(() => {
66
+ delete process.env.CORS_ALLOWED_ORIGINS;
67
+ delete process.env.CORS_HOSTING_ORIGINS;
68
+ _resetCorsPatterns();
69
+ });
70
+
71
+ it('reflects an origin that matches the configured allowlist', () => {
72
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
73
+ _resetCorsPatterns();
74
+
75
+ const headers = buildCorsHeaders('https://myapp.example.com');
76
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
77
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
78
+ });
79
+
80
+ it('never reflects an origin that is not on a configured allowlist', () => {
81
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
82
+ _resetCorsPatterns();
83
+
84
+ const headers = buildCorsHeaders('https://evil.example.com');
85
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
86
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], undefined);
87
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
88
+ });
89
+
90
+ it('never reflects an origin when no allowlist is configured', () => {
91
+ const headers = buildCorsHeaders('https://evil.example.com');
92
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
93
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
94
+ });
95
+
96
+ it('returns no reflection headers when there is no origin', () => {
97
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
98
+ _resetCorsPatterns();
99
+
100
+ assert.deepStrictEqual(buildCorsHeaders(''), { Vary: 'Origin' });
101
+ });
102
+
103
+ it('always sets Vary: Origin so shared caches key on the request origin', () => {
104
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
105
+ _resetCorsPatterns();
106
+
107
+ assert.strictEqual(buildCorsHeaders('https://myapp.example.com').Vary, 'Origin');
108
+ assert.strictEqual(buildCorsHeaders('https://evil.example.com').Vary, 'Origin');
109
+ assert.strictEqual(buildCorsHeaders('').Vary, 'Origin');
110
+ });
111
+
112
+ it('warns only once per distinct disallowed origin', () => {
113
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
114
+ _resetCorsPatterns();
115
+
116
+ const original = console.warn;
117
+ const lines: string[] = [];
118
+ console.warn = (msg: string) => { lines.push(msg); };
119
+ try {
120
+ buildCorsHeaders('https://evil.example.com');
121
+ buildCorsHeaders('https://evil.example.com');
122
+ buildCorsHeaders('https://evil.example.com');
123
+ assert.strictEqual(lines.length, 1, 'repeat requests from one origin should warn once');
124
+
125
+ buildCorsHeaders('https://other.example.com');
126
+ assert.strictEqual(lines.length, 2, 'a distinct origin should still warn');
127
+ assert.ok(lines[0].includes('https://evil.example.com'));
128
+ assert.ok(lines[1].includes('https://other.example.com'));
129
+ } finally {
130
+ console.warn = original;
131
+ }
132
+ });
133
+
134
+ it('does not warn for an allowed origin or an absent origin', () => {
135
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
136
+ _resetCorsPatterns();
137
+
138
+ const original = console.warn;
139
+ const lines: string[] = [];
140
+ console.warn = (msg: string) => { lines.push(msg); };
141
+ try {
142
+ buildCorsHeaders('https://myapp.example.com');
143
+ buildCorsHeaders('');
144
+ assert.strictEqual(lines.length, 0);
145
+ } finally {
146
+ console.warn = original;
147
+ }
148
+ });
149
+ });
150
+
62
151
  // ── isOriginAllowed + getCorsPatterns integration via handler ────────────────
63
152
 
64
153
  // These tests exercise the full CORS flow through createLambdaHandler to
@@ -159,7 +248,12 @@ describe('createLambdaHandler — CORS origin validation', () => {
159
248
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
160
249
  assert.ok(result.headers['Access-Control-Allow-Methods']);
161
250
  assert.ok(result.headers['Access-Control-Allow-Headers']);
162
- assert.strictEqual(result.headers['Access-Control-Max-Age'], '86400');
251
+ assert.strictEqual(result.headers['Access-Control-Max-Age'], CORS_MAX_AGE);
252
+ assert.strictEqual(result.headers['Vary'], 'Origin');
253
+ });
254
+
255
+ it('pins Access-Control-Max-Age at the browser preflight cap', () => {
256
+ assert.strictEqual(CORS_MAX_AGE, '7200');
163
257
  });
164
258
 
165
259
  it('OPTIONS preflight with rejected origin returns 403', async () => {
package/src/cors.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ /**
5
+ * `Access-Control-Max-Age` for preflight responses, in seconds.
6
+ *
7
+ * Chromium caps the preflight cache at 7200s and silently clamps anything
8
+ * higher, so a larger value buys nothing while widening the window in which a
9
+ * stale per-origin grant can be served. Shared by the Lambda handler and the
10
+ * local dev server so the two can't drift.
11
+ */
12
+ export const CORS_MAX_AGE = '7200';
13
+
4
14
  /**
5
15
  * Parse a comma-separated CORS origin string into anchored RegExp patterns.
6
16
  *
@@ -72,20 +82,67 @@ export function isOriginAllowed(origin: string): boolean {
72
82
  return patterns.some(re => re.test(origin));
73
83
  }
74
84
 
85
+ /**
86
+ * Distinct origins already warned about, so a caller retrying — or a bot
87
+ * spraying bogus `Origin` values — can't amplify one log line per request now
88
+ * that this helper runs on every response path.
89
+ */
90
+ const warnedOrigins = new Set<string>();
91
+
92
+ /** Cap on {@link warnedOrigins} so an untrusted input can't grow it unbounded. */
93
+ const WARNED_ORIGINS_LIMIT = 100;
94
+
95
+ function warnDisallowedOriginOnce(origin: string): void {
96
+ if (warnedOrigins.has(origin)) return;
97
+ if (warnedOrigins.size < WARNED_ORIGINS_LIMIT) warnedOrigins.add(origin);
98
+ const example = 'CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$';
99
+ console.warn(
100
+ `[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: ${example}`
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Build the CORS response headers for a request origin.
106
+ *
107
+ * Only reflects the origin when it matches the configured allowlist. When no
108
+ * allowlist is configured, or the origin is configured-but-not-allowed, no
109
+ * `Access-Control-Allow-Origin` / `Access-Control-Allow-Credentials` headers
110
+ * are emitted, so a disallowed origin is never reflected back.
111
+ *
112
+ * `Vary: Origin` is always emitted, including on the not-allowed path: the
113
+ * response headers depend on the request `Origin`, so any shared cache (CDN,
114
+ * forward proxy) must key on it or it can serve one origin's grant — or one
115
+ * origin's *absence* of a grant — to a different origin.
116
+ *
117
+ * @param origin - The `Origin` header value from the request (may be empty)
118
+ * @returns The CORS headers to merge into the response
119
+ */
120
+ export function buildCorsHeaders(origin: string): Record<string, string> {
121
+ const headers: Record<string, string> = { Vary: 'Origin' };
122
+ if (isOriginAllowed(origin)) {
123
+ headers['Access-Control-Allow-Origin'] = origin;
124
+ headers['Access-Control-Allow-Credentials'] = 'true';
125
+ } else if (origin) {
126
+ warnDisallowedOriginOnce(origin);
127
+ }
128
+ return headers;
129
+ }
130
+
75
131
  /**
76
132
  * Build a 403 Forbidden response for cross-origin requests from disallowed origins.
77
133
  */
78
134
  export function corsRejection(): { statusCode: number; headers: Record<string, string>; body: string } {
79
135
  return {
80
136
  statusCode: 403,
81
- headers: { 'Content-Type': 'application/json' },
137
+ headers: { 'Content-Type': 'application/json', Vary: 'Origin' },
82
138
  body: JSON.stringify({ error: 'Forbidden: cross-origin request rejected' }),
83
139
  };
84
140
  }
85
141
 
86
142
  /**
87
- * Reset the lazy CORS pattern cache. **For testing only.**
143
+ * Reset the lazy CORS pattern cache and the warned-origin set. **For testing only.**
88
144
  */
89
145
  export function _resetCorsPatterns(): void {
90
146
  _corsPatterns = undefined;
147
+ warnedOrigins.clear();
91
148
  }
@@ -3,7 +3,35 @@
3
3
 
4
4
  import { describe, it } from 'node:test';
5
5
  import assert from 'node:assert';
6
- import { ApiError, isBlocksError, hasAuthError } from './errors.js';
6
+ import { ApiError, DEFAULT_API_ERROR_NAME, isBlocksError, hasAuthError } from './errors.js';
7
+
8
+ describe('ApiError constructor', () => {
9
+ it('exposes message and status, and stays a real Error', () => {
10
+ const e = new ApiError('Not found', 404);
11
+ assert.ok(e instanceof Error);
12
+ assert.strictEqual(e.message, 'Not found');
13
+ assert.strictEqual(e.status, 404);
14
+ });
15
+
16
+ it('defaults name to ApiError and retriable to false', () => {
17
+ const e = new ApiError('boom', 500);
18
+ assert.strictEqual(e.name, DEFAULT_API_ERROR_NAME);
19
+ assert.strictEqual(e.retriable, false);
20
+ });
21
+
22
+ it('takes name, cause and retriable from the options argument', () => {
23
+ const cause = new Error('root');
24
+ const e = new ApiError('Username already taken', 409, {
25
+ name: 'ConditionalCheckFailedException',
26
+ cause,
27
+ retriable: true,
28
+ });
29
+ assert.strictEqual(e.name, 'ConditionalCheckFailedException');
30
+ assert.strictEqual(e.status, 409);
31
+ assert.strictEqual(e.retriable, true);
32
+ assert.strictEqual(e.cause, cause);
33
+ });
34
+ });
7
35
 
8
36
  describe('isBlocksError', () => {
9
37
  it('matches a thrown ApiError by name', () => {