@aws-blocks/core 0.1.13 → 0.1.18

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 (65) hide show
  1. package/README.md +180 -17
  2. package/dist/cdk/blocks-backend.d.ts +4 -0
  3. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  4. package/dist/cdk/blocks-backend.js +23 -1
  5. package/dist/cdk/blocks-backend.test.js +71 -1
  6. package/dist/cdk/blocks-stack.test.js +32 -1
  7. package/dist/cdk/index.d.ts +13 -0
  8. package/dist/cdk/index.d.ts.map +1 -1
  9. package/dist/cdk/index.js +24 -0
  10. package/dist/cors.d.ts +27 -1
  11. package/dist/cors.d.ts.map +1 -1
  12. package/dist/cors.js +55 -2
  13. package/dist/cors.test.js +81 -2
  14. package/dist/errors.test.js +26 -1
  15. package/dist/hosting.d.ts.map +1 -1
  16. package/dist/hosting.js +26 -1
  17. package/dist/hosting.test.js +73 -0
  18. package/dist/lambda-handler.d.ts.map +1 -1
  19. package/dist/lambda-handler.js +4 -17
  20. package/dist/lambda-handler.test.js +59 -2
  21. package/dist/rpc.test.js +77 -1
  22. package/dist/scripts/console.d.ts.map +1 -1
  23. package/dist/scripts/console.js +30 -2
  24. package/dist/scripts/deploy-stream.d.ts +181 -0
  25. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  26. package/dist/scripts/deploy-stream.js +332 -0
  27. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  28. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  29. package/dist/scripts/deploy-stream.test.js +845 -0
  30. package/dist/scripts/deploy.d.ts.map +1 -1
  31. package/dist/scripts/deploy.js +16 -9
  32. package/dist/scripts/dev-server-cors.test.js +19 -1
  33. package/dist/scripts/dev-server-rpc.test.js +50 -0
  34. package/dist/scripts/dev-server.d.ts +8 -0
  35. package/dist/scripts/dev-server.d.ts.map +1 -1
  36. package/dist/scripts/dev-server.js +35 -8
  37. package/dist/scripts/sandbox.js +1 -1
  38. package/dist/telemetry/client.js +4 -4
  39. package/dist/telemetry/telemetry-send-worker.js +4 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +10 -1
  43. package/src/cdk/blocks-backend.test.ts +90 -1
  44. package/src/cdk/blocks-backend.ts +24 -1
  45. package/src/cdk/blocks-stack.test.ts +41 -1
  46. package/src/cdk/index.ts +25 -0
  47. package/src/cors.test.ts +96 -2
  48. package/src/cors.ts +59 -2
  49. package/src/errors.test.ts +29 -1
  50. package/src/hosting.test.ts +107 -0
  51. package/src/hosting.ts +27 -1
  52. package/src/lambda-handler.test.ts +71 -2
  53. package/src/lambda-handler.ts +4 -20
  54. package/src/rpc.test.ts +96 -1
  55. package/src/scripts/console.ts +29 -2
  56. package/src/scripts/deploy-stream.test.ts +1035 -0
  57. package/src/scripts/deploy-stream.ts +475 -0
  58. package/src/scripts/deploy.ts +18 -11
  59. package/src/scripts/dev-server-cors.test.ts +26 -1
  60. package/src/scripts/dev-server-rpc.test.ts +54 -0
  61. package/src/scripts/dev-server.ts +38 -8
  62. package/src/scripts/sandbox.ts +1 -1
  63. package/src/telemetry/client.ts +4 -4
  64. package/src/telemetry/telemetry-send-worker.ts +5 -0
  65. package/src/version.ts +1 -1
package/dist/cors.js CHANGED
@@ -1,5 +1,14 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * `Access-Control-Max-Age` for preflight responses, in seconds.
5
+ *
6
+ * Chromium caps the preflight cache at 7200s and silently clamps anything
7
+ * higher, so a larger value buys nothing while widening the window in which a
8
+ * stale per-origin grant can be served. Shared by the Lambda handler and the
9
+ * local dev server so the two can't drift.
10
+ */
11
+ export const CORS_MAX_AGE = '7200';
3
12
  /**
4
13
  * Parse a comma-separated CORS origin string into anchored RegExp patterns.
5
14
  *
@@ -67,19 +76,63 @@ export function isOriginAllowed(origin) {
67
76
  return false;
68
77
  return patterns.some(re => re.test(origin));
69
78
  }
79
+ /**
80
+ * Distinct origins already warned about, so a caller retrying — or a bot
81
+ * spraying bogus `Origin` values — can't amplify one log line per request now
82
+ * that this helper runs on every response path.
83
+ */
84
+ const warnedOrigins = new Set();
85
+ /** Cap on {@link warnedOrigins} so an untrusted input can't grow it unbounded. */
86
+ const WARNED_ORIGINS_LIMIT = 100;
87
+ function warnDisallowedOriginOnce(origin) {
88
+ if (warnedOrigins.has(origin))
89
+ return;
90
+ if (warnedOrigins.size < WARNED_ORIGINS_LIMIT)
91
+ warnedOrigins.add(origin);
92
+ const example = 'CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$';
93
+ console.warn(`[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: ${example}`);
94
+ }
95
+ /**
96
+ * Build the CORS response headers for a request origin.
97
+ *
98
+ * Only reflects the origin when it matches the configured allowlist. When no
99
+ * allowlist is configured, or the origin is configured-but-not-allowed, no
100
+ * `Access-Control-Allow-Origin` / `Access-Control-Allow-Credentials` headers
101
+ * are emitted, so a disallowed origin is never reflected back.
102
+ *
103
+ * `Vary: Origin` is always emitted, including on the not-allowed path: the
104
+ * response headers depend on the request `Origin`, so any shared cache (CDN,
105
+ * forward proxy) must key on it or it can serve one origin's grant — or one
106
+ * origin's *absence* of a grant — to a different origin.
107
+ *
108
+ * @param origin - The `Origin` header value from the request (may be empty)
109
+ * @returns The CORS headers to merge into the response
110
+ */
111
+ export function buildCorsHeaders(origin) {
112
+ const headers = { Vary: 'Origin' };
113
+ if (isOriginAllowed(origin)) {
114
+ headers['Access-Control-Allow-Origin'] = origin;
115
+ headers['Access-Control-Allow-Credentials'] = 'true';
116
+ }
117
+ else if (origin) {
118
+ warnDisallowedOriginOnce(origin);
119
+ }
120
+ return headers;
121
+ }
70
122
  /**
71
123
  * Build a 403 Forbidden response for cross-origin requests from disallowed origins.
72
124
  */
73
125
  export function corsRejection() {
74
126
  return {
75
127
  statusCode: 403,
76
- headers: { 'Content-Type': 'application/json' },
128
+ headers: { 'Content-Type': 'application/json', Vary: 'Origin' },
77
129
  body: JSON.stringify({ error: 'Forbidden: cross-origin request rejected' }),
78
130
  };
79
131
  }
80
132
  /**
81
- * Reset the lazy CORS pattern cache. **For testing only.**
133
+ * Reset the lazy CORS pattern cache and the warned-origin set. **For testing only.**
82
134
  */
83
135
  export function _resetCorsPatterns() {
84
136
  _corsPatterns = undefined;
137
+ warnedOrigins.clear();
85
138
  }
package/dist/cors.test.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it, beforeEach } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
5
+ import { parseCorsPatterns, _resetCorsPatterns, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
6
6
  import { createLambdaHandler } from './lambda-handler.js';
7
7
  import { clearRouteRegistry } from './raw-route.js';
8
8
  // ── parseCorsPatterns unit tests ────────────────────────────────────────────
@@ -49,6 +49,81 @@ describe('parseCorsPatterns', () => {
49
49
  assert.ok(patterns[0].test('http://localhost:9999'));
50
50
  });
51
51
  });
52
+ // ── buildCorsHeaders unit tests ─────────────────────────────────────────────
53
+ describe('buildCorsHeaders', () => {
54
+ beforeEach(() => {
55
+ delete process.env.CORS_ALLOWED_ORIGINS;
56
+ delete process.env.CORS_HOSTING_ORIGINS;
57
+ _resetCorsPatterns();
58
+ });
59
+ it('reflects an origin that matches the configured allowlist', () => {
60
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
61
+ _resetCorsPatterns();
62
+ const headers = buildCorsHeaders('https://myapp.example.com');
63
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
64
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
65
+ });
66
+ it('never reflects an origin that is not on a configured allowlist', () => {
67
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
68
+ _resetCorsPatterns();
69
+ const headers = buildCorsHeaders('https://evil.example.com');
70
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
71
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], undefined);
72
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
73
+ });
74
+ it('never reflects an origin when no allowlist is configured', () => {
75
+ const headers = buildCorsHeaders('https://evil.example.com');
76
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
77
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
78
+ });
79
+ it('returns no reflection headers when there is no origin', () => {
80
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
81
+ _resetCorsPatterns();
82
+ assert.deepStrictEqual(buildCorsHeaders(''), { Vary: 'Origin' });
83
+ });
84
+ it('always sets Vary: Origin so shared caches key on the request origin', () => {
85
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
86
+ _resetCorsPatterns();
87
+ assert.strictEqual(buildCorsHeaders('https://myapp.example.com').Vary, 'Origin');
88
+ assert.strictEqual(buildCorsHeaders('https://evil.example.com').Vary, 'Origin');
89
+ assert.strictEqual(buildCorsHeaders('').Vary, 'Origin');
90
+ });
91
+ it('warns only once per distinct disallowed origin', () => {
92
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
93
+ _resetCorsPatterns();
94
+ const original = console.warn;
95
+ const lines = [];
96
+ console.warn = (msg) => { lines.push(msg); };
97
+ try {
98
+ buildCorsHeaders('https://evil.example.com');
99
+ buildCorsHeaders('https://evil.example.com');
100
+ buildCorsHeaders('https://evil.example.com');
101
+ assert.strictEqual(lines.length, 1, 'repeat requests from one origin should warn once');
102
+ buildCorsHeaders('https://other.example.com');
103
+ assert.strictEqual(lines.length, 2, 'a distinct origin should still warn');
104
+ assert.ok(lines[0].includes('https://evil.example.com'));
105
+ assert.ok(lines[1].includes('https://other.example.com'));
106
+ }
107
+ finally {
108
+ console.warn = original;
109
+ }
110
+ });
111
+ it('does not warn for an allowed origin or an absent origin', () => {
112
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
113
+ _resetCorsPatterns();
114
+ const original = console.warn;
115
+ const lines = [];
116
+ console.warn = (msg) => { lines.push(msg); };
117
+ try {
118
+ buildCorsHeaders('https://myapp.example.com');
119
+ buildCorsHeaders('');
120
+ assert.strictEqual(lines.length, 0);
121
+ }
122
+ finally {
123
+ console.warn = original;
124
+ }
125
+ });
126
+ });
52
127
  // ── isOriginAllowed + getCorsPatterns integration via handler ────────────────
53
128
  // These tests exercise the full CORS flow through createLambdaHandler to
54
129
  // verify origin validation, header injection, and rejection work end-to-end.
@@ -137,7 +212,11 @@ describe('createLambdaHandler — CORS origin validation', () => {
137
212
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
138
213
  assert.ok(result.headers['Access-Control-Allow-Methods']);
139
214
  assert.ok(result.headers['Access-Control-Allow-Headers']);
140
- assert.strictEqual(result.headers['Access-Control-Max-Age'], '86400');
215
+ assert.strictEqual(result.headers['Access-Control-Max-Age'], CORS_MAX_AGE);
216
+ assert.strictEqual(result.headers['Vary'], 'Origin');
217
+ });
218
+ it('pins Access-Control-Max-Age at the browser preflight cap', () => {
219
+ assert.strictEqual(CORS_MAX_AGE, '7200');
141
220
  });
142
221
  it('OPTIONS preflight with rejected origin returns 403', async () => {
143
222
  const result = await invoke(echoBackend, makeEvent({
@@ -2,7 +2,32 @@
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 { ApiError, isBlocksError, hasAuthError } from './errors.js';
5
+ import { ApiError, DEFAULT_API_ERROR_NAME, isBlocksError, hasAuthError } from './errors.js';
6
+ describe('ApiError constructor', () => {
7
+ it('exposes message and status, and stays a real Error', () => {
8
+ const e = new ApiError('Not found', 404);
9
+ assert.ok(e instanceof Error);
10
+ assert.strictEqual(e.message, 'Not found');
11
+ assert.strictEqual(e.status, 404);
12
+ });
13
+ it('defaults name to ApiError and retriable to false', () => {
14
+ const e = new ApiError('boom', 500);
15
+ assert.strictEqual(e.name, DEFAULT_API_ERROR_NAME);
16
+ assert.strictEqual(e.retriable, false);
17
+ });
18
+ it('takes name, cause and retriable from the options argument', () => {
19
+ const cause = new Error('root');
20
+ const e = new ApiError('Username already taken', 409, {
21
+ name: 'ConditionalCheckFailedException',
22
+ cause,
23
+ retriable: true,
24
+ });
25
+ assert.strictEqual(e.name, 'ConditionalCheckFailedException');
26
+ assert.strictEqual(e.status, 409);
27
+ assert.strictEqual(e.retriable, true);
28
+ assert.strictEqual(e.cause, cause);
29
+ });
30
+ });
6
31
  describe('isBlocksError', () => {
7
32
  it('matches a thrown ApiError by name', () => {
8
33
  const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
@@ -1 +1 @@
1
- {"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../src/hosting.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AASnC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAKvC,OAAO,EAIL,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EAC1B,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAIL,KAAK,kBAAkB,EACxB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAGV,aAAa,EACd,MAAM,qBAAqB,CAAC;AAO7B;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,QAAQ,GAAG,MAAM,CAAC;IAChC,8FAA8F;IAC9F,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;;;;;;;;;;;;OAgBG;IACH,iBAAiB,CAAC,EAAE;QAClB,2EAA2E;QAC3E,mBAAmB,CAAC,EAAE,MAAM,CAAC;KAC9B,CAAC;IACF,uEAAuE;IACvE,YAAY,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC;CAC3C,CAAC;AAEF,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;AAE3F;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,mHAAmH;IACnH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,YAAY;IAE3B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IAEb;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB,mEAAmE;IACnE,aAAa,CAAC,EAAE,kBAAkB,CAAC;IAEnC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAGlB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,cAAc,CAAC;IAErB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAGxC,uDAAuD;IACvD,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB,mCAAmC;IACnC,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAE7B,oCAAoC;IACpC,GAAG,CAAC,EAAE,gBAAgB,CAAC;IAEvB,sEAAsE;IACtE,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,mDAAmD;IACnD,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAE/B,wDAAwD;IACxD,UAAU,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,UAAU,CAAC;IAE3C;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE;QACf,IAAI,EAAE,WAAW,GAAG,WAAW,CAAC;QAChC,SAAS,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;IAEF;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,EAAE;QACP,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB;;;;;;;;WAQG;QACH,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IAEF;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE;QACX,OAAO,EAAE,OAAO,CAAC;QACjB,0EAA0E;QAC1E,MAAM,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC;KAC7B,CAAC;IAEF;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE;QACX,iEAAiE;QACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,iEAAiE;QACjE,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IAEF;;;OAGG;IACH,OAAO,CAAC,EAAE;QACR,wCAAwC;QACxC,OAAO,EAAE,OAAO,CAAC;QACjB,+CAA+C;QAC/C,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;IAEF;;;;;;OAMG;IACH,UAAU,CAAC,EAAE;QACX,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,6DAA6D;QAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IAEF;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,oBAAoB,CAAC;CACvC;AAYD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,qBAAa,OAAQ,SAAQ,SAAS;IACpC,2CAA2C;IAC3C,SAAgB,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC;IAC1C,mCAAmC;IACnC,SAAgB,YAAY,EAAE,GAAG,CAAC,cAAc,CAAC,YAAY,CAAC;IAC9D,yDAAyD;IACzD,SAAgB,GAAG,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,SAAgB,WAAW,CAAC,EAAE,GAAG,CAAC,UAAU,CAAC,QAAQ,CAAC;IACtD,wFAAwF;IACxF,SAAgB,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC;IACrD,2FAA2F;IAC3F,SAAgB,eAAe,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC;gBAEzC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY;IAsR7D;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAOvB;;OAEG;IACH,OAAO,CAAC,eAAe;CAwDxB"}
1
+ {"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../src/hosting.ts"],"names":[],"mappings":"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"}
package/dist/hosting.js CHANGED
@@ -11,11 +11,13 @@ import { join, resolve } from 'node:path';
11
11
  import { HostingConstruct, generateBuildId, } from '@aws-blocks/hosting/constructs';
12
12
  import { detectFramework, getAdapter, normalizeBasePath, } from '@aws-blocks/hosting/adapters';
13
13
  import { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
14
+ import { BLOCKS_SANDBOX_DIR } from './common/constants.js';
14
15
  import { registerConfig } from './cdk/config-registry.js';
15
16
  import { getRegisteredRoutes } from './raw-route.js';
16
17
  // ─── Default build output directories per framework ──────────────
17
18
  const DEFAULT_BUILD_DIRS = {
18
19
  nextjs: '.next',
20
+ sveltekit: 'build',
19
21
  spa: 'dist',
20
22
  static: 'dist',
21
23
  };
@@ -164,6 +166,18 @@ export class Hosting extends Construct {
164
166
  const blocksSandboxDir = join(staticDir, '.blocks-sandbox');
165
167
  mkdirSync(blocksSandboxDir, { recursive: true });
166
168
  writeFileSync(join(blocksSandboxDir, 'config.json'), JSON.stringify({ _placeholder: true }));
169
+ // ── 5a. Mark config.json as a no-cache path ─────────────────────
170
+ // config.json is a fixed-name, mutable runtime-config file: it must
171
+ // NOT inherit the content-hashed mutable-asset cache-control
172
+ // (`s-maxage=31536000`) applied to `/assets/<hash>.js`. Registering it
173
+ // as a no-cache path uploads the build-time placeholder with
174
+ // `no-cache, no-store, must-revalidate` so an edge never caches it
175
+ // long-term; the real config is deployed in step 8 with `max-age=60`.
176
+ const configNoCachePath = `${BLOCKS_SANDBOX_DIR}/config.json`;
177
+ const existingNoCachePaths = manifest.staticAssets.noCachePaths ?? [];
178
+ manifest.staticAssets.noCachePaths = existingNoCachePaths.includes(configNoCachePath)
179
+ ? existingNoCachePaths
180
+ : [...existingNoCachePaths, configNoCachePath];
167
181
  // ── 5b. Inject static route for .blocks-sandbox config ──────────
168
182
  // Insert a static route for /.blocks-sandbox/* so CloudFront
169
183
  // serves config.json from S3 instead of routing to compute.
@@ -249,7 +263,18 @@ export class Hosting extends Construct {
249
263
  destinationKeyPrefix: `builds/${buildId}/.blocks-sandbox`,
250
264
  prune: false,
251
265
  distribution: hosting.distribution,
252
- distributionPaths: ['/.blocks-sandbox/*'],
266
+ // The skew-protection viewer-request CloudFront function rewrites the
267
+ // URI to `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the
268
+ // cache lookup, so the real edge cache key lives under `/builds/<id>/`.
269
+ // Invalidating only `/.blocks-sandbox/*` never matches that key and is
270
+ // a no-op for config.json. Invalidate the post-rewrite key too. (The
271
+ // primary guard against staleness is step 5a's no-cache placeholder;
272
+ // this is defense-in-depth so a post-deploy invalidation actually
273
+ // clears any edge entry at its real key.)
274
+ distributionPaths: [
275
+ `/builds/${buildId}/.blocks-sandbox/*`,
276
+ '/.blocks-sandbox/*',
277
+ ],
253
278
  cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
254
279
  });
255
280
  // Ensure the config deployment runs AFTER the hosting construct's
@@ -1134,4 +1134,77 @@ describe('Hosting', () => {
1134
1134
  `found DependsOn=${JSON.stringify(dependsOn)}`);
1135
1135
  });
1136
1136
  });
1137
+ describe('config.json stale-placeholder guard (#173)', () => {
1138
+ // Helper: pull every BucketDeployment custom resource's Properties.
1139
+ const bucketDeployments = (stack) => {
1140
+ const crs = Template.fromStack(stack).findResources('Custom::CDKBucketDeployment');
1141
+ return Object.values(crs).map((cr) => cr.Properties);
1142
+ };
1143
+ it('uploads the placeholder config.json with a no-cache directive, never the 1-year mutable cache-control', () => {
1144
+ // The build-time placeholder (`{_placeholder:true}`) is written into the
1145
+ // static dir. If it inherits the mutable asset tier's
1146
+ // `s-maxage=31536000` and an edge caches it during the deploy window, the
1147
+ // edge serves the placeholder for up to a year — breaking every client
1148
+ // API call. It must instead be uploaded as a no-cache path so the edge
1149
+ // never caches it long-term.
1150
+ createSpaBuildOutput(tmpDir);
1151
+ const app = new App();
1152
+ const stack = new Stack(app, 'PlaceholderCacheStack');
1153
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1154
+ const deployments = bucketDeployments(stack);
1155
+ const cc = (p) => p.SystemMetadata?.['cache-control'];
1156
+ const includes = (p) => p.Include ?? [];
1157
+ const excludes = (p) => p.Exclude ?? [];
1158
+ // A deployment must upload `.blocks-sandbox/config.json` with the
1159
+ // no-cache directive (this is the placeholder upload).
1160
+ const noCacheDeploy = deployments.find((p) => includes(p).includes('.blocks-sandbox/config.json') &&
1161
+ cc(p) === 'no-cache, no-store, must-revalidate');
1162
+ assert.ok(noCacheDeploy, 'placeholder .blocks-sandbox/config.json must be uploaded with ' +
1163
+ '"no-cache, no-store, must-revalidate"');
1164
+ // No deployment may cover the placeholder with the 1-year mutable
1165
+ // cache-control: the mutable/other tier must EXCLUDE it.
1166
+ const leaks = deployments.filter((p) => {
1167
+ const directive = cc(p);
1168
+ return (typeof directive === 'string' &&
1169
+ directive.includes('s-maxage=31536000') &&
1170
+ !excludes(p).includes('.blocks-sandbox/config.json'));
1171
+ });
1172
+ assert.strictEqual(leaks.length, 0, 'no mutable-tier deployment may apply s-maxage=31536000 to ' +
1173
+ '.blocks-sandbox/config.json');
1174
+ });
1175
+ it('registers the placeholder as a no-cache path even for a static-only site (no api)', () => {
1176
+ // The placeholder is always written (step 5), so its no-cache
1177
+ // registration must not depend on `props.api`. This guards against a
1178
+ // regression that moves the registration inside an `if (props.api)`
1179
+ // block, which would reopen the stale-placeholder window for
1180
+ // static-only sites.
1181
+ createSpaBuildOutput(tmpDir);
1182
+ const app = new App();
1183
+ const stack = new Stack(app, 'StaticOnlyPlaceholderStack');
1184
+ new Hosting(stack, 'Hosting', { root: tmpDir });
1185
+ Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', Match.objectLike({
1186
+ Include: Match.arrayWith(['.blocks-sandbox/config.json']),
1187
+ SystemMetadata: Match.objectLike({
1188
+ 'cache-control': 'no-cache, no-store, must-revalidate',
1189
+ }),
1190
+ }));
1191
+ });
1192
+ it('invalidates the post-rewrite cache key (/builds/<id>/.blocks-sandbox/*)', () => {
1193
+ // The viewer-request skew-protection function rewrites the URI to
1194
+ // `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the cache
1195
+ // lookup, so the real edge cache key lives under `/builds/<id>/`.
1196
+ // Invalidating only `/.blocks-sandbox/*` never matches it.
1197
+ createSpaBuildOutput(tmpDir);
1198
+ const app = new App();
1199
+ const stack = new Stack(app, 'InvalidationPathStack');
1200
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1201
+ // Assert via Template + Match so a CDK property rename fails loudly here
1202
+ // rather than silently skipping a structural-heuristic lookup.
1203
+ Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', Match.objectLike({
1204
+ DistributionPaths: Match.arrayWith([
1205
+ Match.stringLikeRegexp('^/builds/.+/\\.blocks-sandbox/\\*$'),
1206
+ ]),
1207
+ }));
1208
+ });
1209
+ });
1137
1210
  });
@@ -1 +1 @@
1
- {"version":3,"file":"lambda-handler.d.ts","sourceRoot":"","sources":["../src/lambda-handler.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAerD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,2BAAkC,CAAC;AAG9D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;CAErB,CAAC;AAoDX;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAWlF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAwD7C;AAiBD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,UAAU,CAWpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAIrE;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,wBAAwB,IAAI,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,IA0BtD,OAAO,GAAG,EAAE,UAAU,aAAa,kBAkElD"}
1
+ {"version":3,"file":"lambda-handler.d.ts","sourceRoot":"","sources":["../src/lambda-handler.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAerD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,2BAAkC,CAAC;AAG9D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;CAErB,CAAC;AAqCX;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAWlF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAwD7C;AAiBD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,UAAU,CAWpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAIrE;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,wBAAwB,IAAI,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,IA0BtD,OAAO,GAAG,EAAE,UAAU,aAAa,kBAiElD"}
@@ -8,7 +8,7 @@ import { matchRoute, lockRouteRegistry } from './raw-route.js';
8
8
  import { registerBuiltinRoutes } from './builtin-routes.js';
9
9
  import { loadConfigToProcessEnv } from './common/config.js';
10
10
  import { parseRpcRequest, successResponse, errorResponse, errorResponseFromCatch, methodNotFoundResponse, } from './rpc.js';
11
- import { getCorsPatterns, isOriginAllowed, corsRejection } from './cors.js';
11
+ import { getCorsPatterns, isOriginAllowed, corsRejection, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
12
12
  export { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
13
13
  /**
14
14
  * AsyncLocalStorage that carries the inbound HTTP request cookies through
@@ -28,18 +28,6 @@ globalThis.__BLOCKS_REQUEST_COOKIES_STORE__ = requestCookies;
28
28
  export const EventSourceMapping = {
29
29
  SQS: 'aws:sqs',
30
30
  };
31
- // ── CORS helpers (private to handler) ───────────────────────────────────────
32
- function buildCorsHeaders(origin) {
33
- const headers = {};
34
- if (isOriginAllowed(origin)) {
35
- headers['Access-Control-Allow-Origin'] = origin;
36
- headers['Access-Control-Allow-Credentials'] = 'true';
37
- }
38
- else if (origin) {
39
- console.warn(`[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$`);
40
- }
41
- return headers;
42
- }
43
31
  // ── Helpers ─────────────────────────────────────────────────────────────────
44
32
  /**
45
33
  * Decode the Lambda event body and return both the raw string and a ReadableStream.
@@ -330,7 +318,7 @@ export function createLambdaHandler(backendFactory) {
330
318
  // Timeout won the race — build a 504 response. Format depends on
331
319
  // whether the request targeted an RPC endpoint (structured JSON-RPC
332
320
  // error envelope) or a plain HTTP path (simple error JSON).
333
- const origin = event.headers?.origin || event.headers?.Origin || '*';
321
+ const origin = event.headers?.origin || event.headers?.Origin || '';
334
322
  const requestPath = getRequestPath(event);
335
323
  const isRpcPath = requestPath === BLOCKS_RPC_PREFIX || requestPath.startsWith(BLOCKS_RPC_PREFIX + '/');
336
324
  const body = isRpcPath
@@ -340,8 +328,7 @@ export function createLambdaHandler(backendFactory) {
340
328
  statusCode: 504,
341
329
  headers: {
342
330
  'Content-Type': 'application/json',
343
- 'Access-Control-Allow-Origin': origin,
344
- 'Access-Control-Allow-Credentials': 'true',
331
+ ...buildCorsHeaders(origin),
345
332
  },
346
333
  body,
347
334
  };
@@ -414,7 +401,7 @@ function createHandler(backend) {
414
401
  ...corsHeaders,
415
402
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
416
403
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
417
- 'Access-Control-Max-Age': '86400',
404
+ 'Access-Control-Max-Age': CORS_MAX_AGE,
418
405
  },
419
406
  body: '',
420
407
  };
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it, beforeEach } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { createLambdaHandler, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
5
+ import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
6
6
  import { registerRoute, clearRouteRegistry } from './raw-route.js';
7
7
  import { decodeRpcResponse } from './rpc.js';
8
8
  beforeEach(() => {
@@ -499,7 +499,7 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
499
499
  assert.strictEqual(body.jsonrpc, '2.0');
500
500
  assert.strictEqual(body.error.code, 504);
501
501
  });
502
- it('504 response includes CORS headers from event origin', async () => {
502
+ it('504 response includes CORS headers for an allowed origin', async () => {
503
503
  const backend = {
504
504
  api: () => ({
505
505
  async echo() {
@@ -508,6 +508,9 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
508
508
  },
509
509
  }),
510
510
  };
511
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
512
+ delete process.env.CORS_HOSTING_ORIGINS;
513
+ _resetCorsPatterns();
511
514
  const event = makeEvent({
512
515
  headers: {
513
516
  'Content-Type': 'application/json',
@@ -519,6 +522,60 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
519
522
  assert.strictEqual(result.statusCode, 504);
520
523
  assert.strictEqual(result.headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
521
524
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
525
+ delete process.env.CORS_ALLOWED_ORIGINS;
526
+ _resetCorsPatterns();
527
+ });
528
+ it('504 response omits CORS headers when no allowlist is configured', async () => {
529
+ const backend = {
530
+ api: () => ({
531
+ async echo() {
532
+ await new Promise(resolve => setTimeout(resolve, 5_000));
533
+ return {};
534
+ },
535
+ }),
536
+ };
537
+ delete process.env.CORS_ALLOWED_ORIGINS;
538
+ delete process.env.CORS_HOSTING_ORIGINS;
539
+ _resetCorsPatterns();
540
+ const event = makeEvent({
541
+ headers: {
542
+ 'Content-Type': 'application/json',
543
+ origin: 'https://evil.example.com',
544
+ },
545
+ });
546
+ const ctx = makeLambdaContext(50);
547
+ const result = await invokeWithContext(backend, event, ctx);
548
+ assert.strictEqual(result.statusCode, 504);
549
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
550
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
551
+ delete process.env.CORS_ALLOWED_ORIGINS;
552
+ _resetCorsPatterns();
553
+ });
554
+ it('disallowed origin with a configured allowlist is rejected with 403 before the timeout path', async () => {
555
+ const backend = {
556
+ api: () => ({
557
+ async echo() {
558
+ await new Promise(resolve => setTimeout(resolve, 5_000));
559
+ return {};
560
+ },
561
+ }),
562
+ };
563
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
564
+ delete process.env.CORS_HOSTING_ORIGINS;
565
+ _resetCorsPatterns();
566
+ const event = makeEvent({
567
+ headers: {
568
+ 'Content-Type': 'application/json',
569
+ origin: 'https://evil.example.com',
570
+ },
571
+ });
572
+ const ctx = makeLambdaContext(50);
573
+ const result = await invokeWithContext(backend, event, ctx);
574
+ assert.strictEqual(result.statusCode, 403);
575
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
576
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
577
+ delete process.env.CORS_ALLOWED_ORIGINS;
578
+ _resetCorsPatterns();
522
579
  });
523
580
  });
524
581
  describe('createLambdaHandler — async events bypass timeout guard', () => {
package/dist/rpc.test.js CHANGED
@@ -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 { parseRpcRequest, RpcErrorCode } from './rpc.js';
5
+ import { decodeRpcResponse, errorResponseFromCatch, parseRpcRequest, RpcErrorCode } from './rpc.js';
6
+ import { ApiError, isBlocksError } from './errors.js';
6
7
  describe('-32600 Invalid Request error shape', () => {
7
8
  it('returns proper JSON-RPC 2.0 envelope with error code', () => {
8
9
  const result = parseRpcRequest(JSON.stringify({ method: 'ns.method', id: 1 }));
@@ -58,3 +59,78 @@ describe('-32600 Invalid Request error shape', () => {
58
59
  }
59
60
  });
60
61
  });
62
+ describe('params decoding', () => {
63
+ it('uses an array of params as positional args', () => {
64
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: ['World', 42], id: 1 }));
65
+ assert.strictEqual(result.ok, true);
66
+ if (result.ok) {
67
+ assert.deepStrictEqual(result.request.args, ['World', 42]);
68
+ assert.strictEqual(result.request.apiNamespace, 'api');
69
+ assert.strictEqual(result.request.method, 'greet');
70
+ }
71
+ });
72
+ it('flattens an object of named params in key order', () => {
73
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: { name: 'World', times: 42 }, id: 1 }));
74
+ assert.strictEqual(result.ok, true);
75
+ if (result.ok)
76
+ assert.deepStrictEqual(result.request.args, ['World', 42]);
77
+ });
78
+ it('yields no args when params is omitted', () => {
79
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.ping', id: 7 }));
80
+ assert.strictEqual(result.ok, true);
81
+ if (result.ok)
82
+ assert.deepStrictEqual(result.request.args, []);
83
+ });
84
+ });
85
+ describe('batch requests (top-level JSON array body)', () => {
86
+ it('rejects an array body as Invalid Request with a null id', () => {
87
+ const result = parseRpcRequest(JSON.stringify([
88
+ { jsonrpc: '2.0', method: 'api.greet', params: ['a'], id: 1 },
89
+ { jsonrpc: '2.0', method: 'api.greet', params: ['b'], id: 2 },
90
+ ]));
91
+ assert.strictEqual(result.ok, false);
92
+ if (!result.ok) {
93
+ const parsed = JSON.parse(result.response);
94
+ assert.strictEqual(parsed.error.code, RpcErrorCode.InvalidRequest);
95
+ assert.strictEqual(parsed.error.data.name, 'InvalidRequest');
96
+ assert.strictEqual(parsed.id, null);
97
+ }
98
+ });
99
+ it('reports a parse error for a body that is not JSON at all', () => {
100
+ const result = parseRpcRequest('{oops');
101
+ assert.strictEqual(result.ok, false);
102
+ if (!result.ok) {
103
+ const parsed = JSON.parse(result.response);
104
+ assert.strictEqual(parsed.error.code, RpcErrorCode.ParseError);
105
+ assert.strictEqual(parsed.id, null);
106
+ }
107
+ });
108
+ });
109
+ describe('ApiError status ↔ JSON-RPC error code', () => {
110
+ it('encodes the HTTP status as the error code, with name and retriable in data', () => {
111
+ const encoded = errorResponseFromCatch(new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }), 1);
112
+ const parsed = JSON.parse(encoded);
113
+ assert.strictEqual(parsed.error.code, 409);
114
+ assert.strictEqual(parsed.error.message, 'Username already taken');
115
+ assert.strictEqual(parsed.error.data.name, 'ConditionalCheckFailedException');
116
+ assert.strictEqual(parsed.error.data.retriable, true);
117
+ });
118
+ it('encodes a non-ApiError throw as code 500 with no data.name', () => {
119
+ const parsed = JSON.parse(errorResponseFromCatch(new Error('plain'), 2));
120
+ assert.strictEqual(parsed.error.code, 500);
121
+ assert.strictEqual(parsed.error.data, undefined);
122
+ });
123
+ it('round-trips status, name and retriable back into an ApiError on the client', () => {
124
+ const wire = JSON.parse(errorResponseFromCatch(new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }), 1));
125
+ assert.throws(() => decodeRpcResponse(wire), (e) => {
126
+ assert.ok(e instanceof ApiError);
127
+ assert.strictEqual(e.status, 409);
128
+ assert.strictEqual(e.retriable, true);
129
+ assert.ok(isBlocksError(e, 'ConditionalCheckFailedException'));
130
+ return true;
131
+ });
132
+ });
133
+ it('decodes reserved -32xxx codes as status 500', () => {
134
+ assert.throws(() => decodeRpcResponse({ jsonrpc: '2.0', error: { code: RpcErrorCode.InvalidRequest, message: 'Invalid Request' }, id: null }), (e) => e instanceof ApiError && e.status === 500);
135
+ });
136
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/scripts/console.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,cAAc,iBAqBxD"}
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/scripts/console.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA6BD,wBAAsB,WAAW,CAAC,OAAO,EAAE,cAAc,iBAqBxD"}