@aws-blocks/core 0.1.12 → 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 (61) 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/redact.d.ts +3 -2
  14. package/dist/redact.d.ts.map +1 -1
  15. package/dist/redact.js +4 -3
  16. package/dist/redact.test.js +9 -0
  17. package/dist/rpc.test.js +77 -1
  18. package/dist/scripts/console.d.ts.map +1 -1
  19. package/dist/scripts/console.js +30 -2
  20. package/dist/scripts/deploy-stream.d.ts +181 -0
  21. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  22. package/dist/scripts/deploy-stream.js +332 -0
  23. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  24. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  25. package/dist/scripts/deploy-stream.test.js +845 -0
  26. package/dist/scripts/deploy.d.ts.map +1 -1
  27. package/dist/scripts/deploy.js +16 -9
  28. package/dist/scripts/dev-server-cors.test.js +19 -1
  29. package/dist/scripts/dev-server-rpc.test.d.ts +2 -0
  30. package/dist/scripts/dev-server-rpc.test.d.ts.map +1 -0
  31. package/dist/scripts/dev-server-rpc.test.js +157 -0
  32. package/dist/scripts/dev-server.d.ts +8 -0
  33. package/dist/scripts/dev-server.d.ts.map +1 -1
  34. package/dist/scripts/dev-server.js +35 -8
  35. package/dist/scripts/sandbox.js +1 -1
  36. package/dist/telemetry/client.js +4 -4
  37. package/dist/telemetry/telemetry-send-worker.js +4 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +10 -1
  41. package/src/cors.test.ts +96 -2
  42. package/src/cors.ts +59 -2
  43. package/src/errors.test.ts +29 -1
  44. package/src/hosting.test.ts +107 -0
  45. package/src/hosting.ts +27 -1
  46. package/src/lambda-handler.test.ts +71 -2
  47. package/src/lambda-handler.ts +4 -20
  48. package/src/redact.test.ts +12 -0
  49. package/src/redact.ts +4 -3
  50. package/src/rpc.test.ts +96 -1
  51. package/src/scripts/console.ts +29 -2
  52. package/src/scripts/deploy-stream.test.ts +1035 -0
  53. package/src/scripts/deploy-stream.ts +475 -0
  54. package/src/scripts/deploy.ts +18 -11
  55. package/src/scripts/dev-server-cors.test.ts +26 -1
  56. package/src/scripts/dev-server-rpc.test.ts +169 -0
  57. package/src/scripts/dev-server.ts +38 -8
  58. package/src/scripts/sandbox.ts +1 -1
  59. package/src/telemetry/client.ts +4 -4
  60. package/src/telemetry/telemetry-send-worker.ts +5 -0
  61. package/src/version.ts +1 -1
@@ -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', () => {
@@ -1507,4 +1507,111 @@ describe('Hosting', () => {
1507
1507
  );
1508
1508
  });
1509
1509
  });
1510
+
1511
+ describe('config.json stale-placeholder guard (#173)', () => {
1512
+ // Helper: pull every BucketDeployment custom resource's Properties.
1513
+ const bucketDeployments = (stack: Stack) => {
1514
+ const crs = Template.fromStack(stack).findResources(
1515
+ 'Custom::CDKBucketDeployment',
1516
+ );
1517
+ return Object.values(crs).map(
1518
+ (cr) => (cr as { Properties: Record<string, unknown> }).Properties,
1519
+ );
1520
+ };
1521
+
1522
+ it('uploads the placeholder config.json with a no-cache directive, never the 1-year mutable cache-control', () => {
1523
+ // The build-time placeholder (`{_placeholder:true}`) is written into the
1524
+ // static dir. If it inherits the mutable asset tier's
1525
+ // `s-maxage=31536000` and an edge caches it during the deploy window, the
1526
+ // edge serves the placeholder for up to a year — breaking every client
1527
+ // API call. It must instead be uploaded as a no-cache path so the edge
1528
+ // never caches it long-term.
1529
+ createSpaBuildOutput(tmpDir);
1530
+ const app = new App();
1531
+ const stack = new Stack(app, 'PlaceholderCacheStack');
1532
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1533
+
1534
+ const deployments = bucketDeployments(stack);
1535
+ const cc = (p: Record<string, unknown>) =>
1536
+ (p.SystemMetadata as Record<string, string> | undefined)?.['cache-control'];
1537
+ const includes = (p: Record<string, unknown>) =>
1538
+ (p.Include as string[] | undefined) ?? [];
1539
+ const excludes = (p: Record<string, unknown>) =>
1540
+ (p.Exclude as string[] | undefined) ?? [];
1541
+
1542
+ // A deployment must upload `.blocks-sandbox/config.json` with the
1543
+ // no-cache directive (this is the placeholder upload).
1544
+ const noCacheDeploy = deployments.find(
1545
+ (p) =>
1546
+ includes(p).includes('.blocks-sandbox/config.json') &&
1547
+ cc(p) === 'no-cache, no-store, must-revalidate',
1548
+ );
1549
+ assert.ok(
1550
+ noCacheDeploy,
1551
+ 'placeholder .blocks-sandbox/config.json must be uploaded with ' +
1552
+ '"no-cache, no-store, must-revalidate"',
1553
+ );
1554
+
1555
+ // No deployment may cover the placeholder with the 1-year mutable
1556
+ // cache-control: the mutable/other tier must EXCLUDE it.
1557
+ const leaks = deployments.filter((p) => {
1558
+ const directive = cc(p);
1559
+ return (
1560
+ typeof directive === 'string' &&
1561
+ directive.includes('s-maxage=31536000') &&
1562
+ !excludes(p).includes('.blocks-sandbox/config.json')
1563
+ );
1564
+ });
1565
+ assert.strictEqual(
1566
+ leaks.length,
1567
+ 0,
1568
+ 'no mutable-tier deployment may apply s-maxage=31536000 to ' +
1569
+ '.blocks-sandbox/config.json',
1570
+ );
1571
+ });
1572
+
1573
+ it('registers the placeholder as a no-cache path even for a static-only site (no api)', () => {
1574
+ // The placeholder is always written (step 5), so its no-cache
1575
+ // registration must not depend on `props.api`. This guards against a
1576
+ // regression that moves the registration inside an `if (props.api)`
1577
+ // block, which would reopen the stale-placeholder window for
1578
+ // static-only sites.
1579
+ createSpaBuildOutput(tmpDir);
1580
+ const app = new App();
1581
+ const stack = new Stack(app, 'StaticOnlyPlaceholderStack');
1582
+ new Hosting(stack, 'Hosting', { root: tmpDir });
1583
+
1584
+ Template.fromStack(stack).hasResourceProperties(
1585
+ 'Custom::CDKBucketDeployment',
1586
+ Match.objectLike({
1587
+ Include: Match.arrayWith(['.blocks-sandbox/config.json']),
1588
+ SystemMetadata: Match.objectLike({
1589
+ 'cache-control': 'no-cache, no-store, must-revalidate',
1590
+ }),
1591
+ }),
1592
+ );
1593
+ });
1594
+
1595
+ it('invalidates the post-rewrite cache key (/builds/<id>/.blocks-sandbox/*)', () => {
1596
+ // The viewer-request skew-protection function rewrites the URI to
1597
+ // `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the cache
1598
+ // lookup, so the real edge cache key lives under `/builds/<id>/`.
1599
+ // Invalidating only `/.blocks-sandbox/*` never matches it.
1600
+ createSpaBuildOutput(tmpDir);
1601
+ const app = new App();
1602
+ const stack = new Stack(app, 'InvalidationPathStack');
1603
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1604
+
1605
+ // Assert via Template + Match so a CDK property rename fails loudly here
1606
+ // rather than silently skipping a structural-heuristic lookup.
1607
+ Template.fromStack(stack).hasResourceProperties(
1608
+ 'Custom::CDKBucketDeployment',
1609
+ Match.objectLike({
1610
+ DistributionPaths: Match.arrayWith([
1611
+ Match.stringLikeRegexp('^/builds/.+/\\.blocks-sandbox/\\*$'),
1612
+ ]),
1613
+ }),
1614
+ );
1615
+ });
1616
+ });
1510
1617
  });
package/src/hosting.ts CHANGED
@@ -35,6 +35,7 @@ import type {
35
35
  FrameworkType,
36
36
  } from '@aws-blocks/hosting';
37
37
  import { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
38
+ import { BLOCKS_SANDBOX_DIR } from './common/constants.js';
38
39
  import { registerConfig } from './cdk/config-registry.js';
39
40
  import { getRegisteredRoutes } from './raw-route.js';
40
41
 
@@ -341,6 +342,7 @@ export interface HostingProps {
341
342
 
342
343
  const DEFAULT_BUILD_DIRS: Record<string, string> = {
343
344
  nextjs: '.next',
345
+ sveltekit: 'build',
344
346
  spa: 'dist',
345
347
  static: 'dist',
346
348
  };
@@ -509,6 +511,19 @@ export class Hosting extends Construct {
509
511
  JSON.stringify({ _placeholder: true }),
510
512
  );
511
513
 
514
+ // ── 5a. Mark config.json as a no-cache path ─────────────────────
515
+ // config.json is a fixed-name, mutable runtime-config file: it must
516
+ // NOT inherit the content-hashed mutable-asset cache-control
517
+ // (`s-maxage=31536000`) applied to `/assets/<hash>.js`. Registering it
518
+ // as a no-cache path uploads the build-time placeholder with
519
+ // `no-cache, no-store, must-revalidate` so an edge never caches it
520
+ // long-term; the real config is deployed in step 8 with `max-age=60`.
521
+ const configNoCachePath = `${BLOCKS_SANDBOX_DIR}/config.json`;
522
+ const existingNoCachePaths = manifest.staticAssets.noCachePaths ?? [];
523
+ manifest.staticAssets.noCachePaths = existingNoCachePaths.includes(configNoCachePath)
524
+ ? existingNoCachePaths
525
+ : [...existingNoCachePaths, configNoCachePath];
526
+
512
527
  // ── 5b. Inject static route for .blocks-sandbox config ──────────
513
528
  // Insert a static route for /.blocks-sandbox/* so CloudFront
514
529
  // serves config.json from S3 instead of routing to compute.
@@ -606,7 +621,18 @@ export class Hosting extends Construct {
606
621
  destinationKeyPrefix: `builds/${buildId}/.blocks-sandbox`,
607
622
  prune: false,
608
623
  distribution: hosting.distribution,
609
- distributionPaths: ['/.blocks-sandbox/*'],
624
+ // The skew-protection viewer-request CloudFront function rewrites the
625
+ // URI to `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the
626
+ // cache lookup, so the real edge cache key lives under `/builds/<id>/`.
627
+ // Invalidating only `/.blocks-sandbox/*` never matches that key and is
628
+ // a no-op for config.json. Invalidate the post-rewrite key too. (The
629
+ // primary guard against staleness is step 5a's no-cache placeholder;
630
+ // this is defense-in-depth so a post-deploy invalidation actually
631
+ // clears any edge entry at its real key.)
632
+ distributionPaths: [
633
+ `/builds/${buildId}/.blocks-sandbox/*`,
634
+ '/.blocks-sandbox/*',
635
+ ],
610
636
  cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
611
637
  });
612
638
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert';
6
- import { createLambdaHandler, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
6
+ import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
7
7
  import type { LambdaContext } from './lambda-handler.js';
8
8
  import { registerRoute, clearRouteRegistry } from './raw-route.js';
9
9
  import { decodeRpcResponse } from './rpc.js';
@@ -607,7 +607,7 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
607
607
  assert.strictEqual(body.error.code, 504);
608
608
  });
609
609
 
610
- it('504 response includes CORS headers from event origin', async () => {
610
+ it('504 response includes CORS headers for an allowed origin', async () => {
611
611
  const backend = {
612
612
  api: () => ({
613
613
  async echo() {
@@ -617,6 +617,10 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
617
617
  }),
618
618
  };
619
619
 
620
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
621
+ delete process.env.CORS_HOSTING_ORIGINS;
622
+ _resetCorsPatterns();
623
+
620
624
  const event = makeEvent({
621
625
  headers: {
622
626
  'Content-Type': 'application/json',
@@ -629,6 +633,71 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
629
633
  assert.strictEqual(result.statusCode, 504);
630
634
  assert.strictEqual(result.headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
631
635
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
636
+
637
+ delete process.env.CORS_ALLOWED_ORIGINS;
638
+ _resetCorsPatterns();
639
+ });
640
+
641
+ it('504 response omits CORS headers when no allowlist is configured', async () => {
642
+ const backend = {
643
+ api: () => ({
644
+ async echo() {
645
+ await new Promise(resolve => setTimeout(resolve, 5_000));
646
+ return {};
647
+ },
648
+ }),
649
+ };
650
+
651
+ delete process.env.CORS_ALLOWED_ORIGINS;
652
+ delete process.env.CORS_HOSTING_ORIGINS;
653
+ _resetCorsPatterns();
654
+
655
+ const event = makeEvent({
656
+ headers: {
657
+ 'Content-Type': 'application/json',
658
+ origin: 'https://evil.example.com',
659
+ },
660
+ });
661
+ const ctx = makeLambdaContext(50);
662
+ const result = await invokeWithContext(backend, event, ctx);
663
+
664
+ assert.strictEqual(result.statusCode, 504);
665
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
666
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
667
+
668
+ delete process.env.CORS_ALLOWED_ORIGINS;
669
+ _resetCorsPatterns();
670
+ });
671
+
672
+ it('disallowed origin with a configured allowlist is rejected with 403 before the timeout path', async () => {
673
+ const backend = {
674
+ api: () => ({
675
+ async echo() {
676
+ await new Promise(resolve => setTimeout(resolve, 5_000));
677
+ return {};
678
+ },
679
+ }),
680
+ };
681
+
682
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
683
+ delete process.env.CORS_HOSTING_ORIGINS;
684
+ _resetCorsPatterns();
685
+
686
+ const event = makeEvent({
687
+ headers: {
688
+ 'Content-Type': 'application/json',
689
+ origin: 'https://evil.example.com',
690
+ },
691
+ });
692
+ const ctx = makeLambdaContext(50);
693
+ const result = await invokeWithContext(backend, event, ctx);
694
+
695
+ assert.strictEqual(result.statusCode, 403);
696
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
697
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
698
+
699
+ delete process.env.CORS_ALLOWED_ORIGINS;
700
+ _resetCorsPatterns();
632
701
  });
633
702
  });
634
703
 
@@ -15,7 +15,7 @@ import {
15
15
  errorResponseFromCatch,
16
16
  methodNotFoundResponse,
17
17
  } from './rpc.js';
18
- import { getCorsPatterns, isOriginAllowed, corsRejection } from './cors.js';
18
+ import { getCorsPatterns, isOriginAllowed, corsRejection, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
19
19
 
20
20
  export { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
21
21
 
@@ -39,21 +39,6 @@ export const EventSourceMapping = {
39
39
  SQS: 'aws:sqs',
40
40
  } as const;
41
41
 
42
- // ── CORS helpers (private to handler) ───────────────────────────────────────
43
-
44
- function buildCorsHeaders(origin: string): Record<string, string> {
45
- const headers: Record<string, string> = {};
46
- if (isOriginAllowed(origin)) {
47
- headers['Access-Control-Allow-Origin'] = origin;
48
- headers['Access-Control-Allow-Credentials'] = 'true';
49
- } else if (origin) {
50
- console.warn(
51
- `[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+)?$`
52
- );
53
- }
54
- return headers;
55
- }
56
-
57
42
  // ── Helpers ─────────────────────────────────────────────────────────────────
58
43
 
59
44
  /**
@@ -377,7 +362,7 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
377
362
  // Timeout won the race — build a 504 response. Format depends on
378
363
  // whether the request targeted an RPC endpoint (structured JSON-RPC
379
364
  // error envelope) or a plain HTTP path (simple error JSON).
380
- const origin = event.headers?.origin || event.headers?.Origin || '*';
365
+ const origin = event.headers?.origin || event.headers?.Origin || '';
381
366
  const requestPath = getRequestPath(event);
382
367
  const isRpcPath = requestPath === BLOCKS_RPC_PREFIX || requestPath.startsWith(BLOCKS_RPC_PREFIX + '/');
383
368
  const body = isRpcPath
@@ -387,8 +372,7 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
387
372
  statusCode: 504,
388
373
  headers: {
389
374
  'Content-Type': 'application/json',
390
- 'Access-Control-Allow-Origin': origin,
391
- 'Access-Control-Allow-Credentials': 'true',
375
+ ...buildCorsHeaders(origin),
392
376
  },
393
377
  body,
394
378
  };
@@ -472,7 +456,7 @@ function createHandler(backend: any) {
472
456
  ...corsHeaders,
473
457
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
474
458
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
475
- 'Access-Control-Max-Age': '86400',
459
+ 'Access-Control-Max-Age': CORS_MAX_AGE,
476
460
  },
477
461
  body: '',
478
462
  };
@@ -201,6 +201,18 @@ describe('redactToJson', () => {
201
201
  assert.equal(redactToJson({ big: 10n }), '[unserializable]');
202
202
  });
203
203
 
204
+ it('returns a string for undefined', () => {
205
+ assert.equal(redactToJson(undefined), 'undefined');
206
+ });
207
+
208
+ it('returns a string for function values', () => {
209
+ assert.equal(redactToJson(() => {}), 'undefined');
210
+ });
211
+
212
+ it('returns a string for symbol values', () => {
213
+ assert.equal(redactToJson(Symbol('x')), 'undefined');
214
+ });
215
+
204
216
  it('does not leak secrets even when truncation would apply downstream', () => {
205
217
  const json = redactToJson([{ action: 'signIn', username: 'u', password: 'p'.repeat(50) }]);
206
218
  assert.ok(!json.includes('pppp'));
package/src/redact.ts CHANGED
@@ -131,12 +131,13 @@ export function redactForLogging(value: unknown, seen: WeakSet<object> = new Wea
131
131
 
132
132
  /**
133
133
  * Convenience for log call sites: redact `value` and serialize it to a JSON
134
- * string. Returns a safe placeholder instead of throwing if serialization
135
- * fails (e.g. a BigInt slips through), so logging can never crash a request.
134
+ * string. Returns `'undefined'` when JSON serialization produces no output,
135
+ * or a safe placeholder if serialization throws (e.g. a BigInt slips through),
136
+ * so logging can never crash a request.
136
137
  */
137
138
  export function redactToJson(value: unknown): string {
138
139
  try {
139
- return JSON.stringify(redactForLogging(value));
140
+ return JSON.stringify(redactForLogging(value)) ?? 'undefined';
140
141
  } catch {
141
142
  return '[unserializable]';
142
143
  }
package/src/rpc.test.ts CHANGED
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { describe, it } from 'node:test';
5
5
  import assert from 'node:assert';
6
- import { parseRpcRequest, RpcErrorCode } from './rpc.js';
6
+ import { decodeRpcResponse, errorResponseFromCatch, parseRpcRequest, RpcErrorCode } from './rpc.js';
7
+ import { ApiError, isBlocksError } from './errors.js';
7
8
 
8
9
  describe('-32600 Invalid Request error shape', () => {
9
10
  it('returns proper JSON-RPC 2.0 envelope with error code', () => {
@@ -71,3 +72,97 @@ describe('-32600 Invalid Request error shape', () => {
71
72
  }
72
73
  });
73
74
  });
75
+
76
+ describe('params decoding', () => {
77
+ it('uses an array of params as positional args', () => {
78
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: ['World', 42], id: 1 }));
79
+ assert.strictEqual(result.ok, true);
80
+ if (result.ok) {
81
+ assert.deepStrictEqual(result.request.args, ['World', 42]);
82
+ assert.strictEqual(result.request.apiNamespace, 'api');
83
+ assert.strictEqual(result.request.method, 'greet');
84
+ }
85
+ });
86
+
87
+ it('flattens an object of named params in key order', () => {
88
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: { name: 'World', times: 42 }, id: 1 }));
89
+ assert.strictEqual(result.ok, true);
90
+ if (result.ok) assert.deepStrictEqual(result.request.args, ['World', 42]);
91
+ });
92
+
93
+ it('yields no args when params is omitted', () => {
94
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.ping', id: 7 }));
95
+ assert.strictEqual(result.ok, true);
96
+ if (result.ok) assert.deepStrictEqual(result.request.args, []);
97
+ });
98
+ });
99
+
100
+ describe('batch requests (top-level JSON array body)', () => {
101
+ it('rejects an array body as Invalid Request with a null id', () => {
102
+ const result = parseRpcRequest(JSON.stringify([
103
+ { jsonrpc: '2.0', method: 'api.greet', params: ['a'], id: 1 },
104
+ { jsonrpc: '2.0', method: 'api.greet', params: ['b'], id: 2 },
105
+ ]));
106
+ assert.strictEqual(result.ok, false);
107
+ if (!result.ok) {
108
+ const parsed = JSON.parse(result.response);
109
+ assert.strictEqual(parsed.error.code, RpcErrorCode.InvalidRequest);
110
+ assert.strictEqual(parsed.error.data.name, 'InvalidRequest');
111
+ assert.strictEqual(parsed.id, null);
112
+ }
113
+ });
114
+
115
+ it('reports a parse error for a body that is not JSON at all', () => {
116
+ const result = parseRpcRequest('{oops');
117
+ assert.strictEqual(result.ok, false);
118
+ if (!result.ok) {
119
+ const parsed = JSON.parse(result.response);
120
+ assert.strictEqual(parsed.error.code, RpcErrorCode.ParseError);
121
+ assert.strictEqual(parsed.id, null);
122
+ }
123
+ });
124
+ });
125
+
126
+ describe('ApiError status ↔ JSON-RPC error code', () => {
127
+ it('encodes the HTTP status as the error code, with name and retriable in data', () => {
128
+ const encoded = errorResponseFromCatch(
129
+ new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }),
130
+ 1,
131
+ );
132
+ const parsed = JSON.parse(encoded);
133
+ assert.strictEqual(parsed.error.code, 409);
134
+ assert.strictEqual(parsed.error.message, 'Username already taken');
135
+ assert.strictEqual(parsed.error.data.name, 'ConditionalCheckFailedException');
136
+ assert.strictEqual(parsed.error.data.retriable, true);
137
+ });
138
+
139
+ it('encodes a non-ApiError throw as code 500 with no data.name', () => {
140
+ const parsed = JSON.parse(errorResponseFromCatch(new Error('plain'), 2));
141
+ assert.strictEqual(parsed.error.code, 500);
142
+ assert.strictEqual(parsed.error.data, undefined);
143
+ });
144
+
145
+ it('round-trips status, name and retriable back into an ApiError on the client', () => {
146
+ const wire = JSON.parse(errorResponseFromCatch(
147
+ new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }),
148
+ 1,
149
+ ));
150
+ assert.throws(
151
+ () => decodeRpcResponse(wire),
152
+ (e: unknown) => {
153
+ assert.ok(e instanceof ApiError);
154
+ assert.strictEqual(e.status, 409);
155
+ assert.strictEqual(e.retriable, true);
156
+ assert.ok(isBlocksError(e, 'ConditionalCheckFailedException'));
157
+ return true;
158
+ },
159
+ );
160
+ });
161
+
162
+ it('decodes reserved -32xxx codes as status 500', () => {
163
+ assert.throws(
164
+ () => decodeRpcResponse({ jsonrpc: '2.0', error: { code: RpcErrorCode.InvalidRequest, message: 'Invalid Request' }, id: null }),
165
+ (e: unknown) => e instanceof ApiError && e.status === 500,
166
+ );
167
+ });
168
+ });
@@ -10,6 +10,33 @@ export interface ConsoleOptions {
10
10
  outputsFile?: string;
11
11
  }
12
12
 
13
+ function resolveRegion(): string {
14
+ const fromEnv = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
15
+ if (fromEnv) return fromEnv;
16
+ try {
17
+ const fromConfig = execFileSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8' }).trim();
18
+ if (fromConfig) return fromConfig;
19
+ } catch {
20
+ // aws CLI not configured — fall through to default.
21
+ }
22
+ return 'us-east-1';
23
+ }
24
+
25
+ /** Launch the URL in the default browser. Best-effort: no opener (headless/CI) is not a failure. */
26
+ function openInBrowser(url: string): void {
27
+ const opener =
28
+ process.platform === 'darwin' ? 'open' :
29
+ process.platform === 'win32' ? 'cmd' :
30
+ 'xdg-open';
31
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
32
+ try {
33
+ execFileSync(opener, args, { stdio: 'ignore' });
34
+ } catch {
35
+ // Headless environment (CI, remote shell) — the URL is already printed above.
36
+ console.log('(Could not launch a browser automatically — open the URL above manually.)');
37
+ }
38
+ }
39
+
13
40
  export async function openConsole(options: ConsoleOptions) {
14
41
  return trackCommand('console', async () => {
15
42
  let stackName: string;
@@ -23,12 +50,12 @@ export async function openConsole(options: ConsoleOptions) {
23
50
  throw new Error('Must provide either stackId or outputsFile');
24
51
  }
25
52
 
26
- const region = execFileSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8' }).trim() || 'us-east-1';
53
+ const region = resolveRegion();
27
54
  const stackUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks?filteringText=${encodeURIComponent(stackName)}`;
28
55
 
29
56
  console.log('Opening AWS Console...');
30
57
  console.log(stackUrl);
31
58
 
32
- execFileSync('open', [stackUrl], { stdio: 'inherit' });
59
+ openInBrowser(stackUrl);
33
60
  });
34
61
  }