@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.
- package/README.md +180 -17
- package/dist/cors.d.ts +27 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +55 -2
- package/dist/cors.test.js +81 -2
- package/dist/errors.test.js +26 -1
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +26 -1
- package/dist/hosting.test.js +73 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +4 -17
- package/dist/lambda-handler.test.js +59 -2
- package/dist/redact.d.ts +3 -2
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +4 -3
- package/dist/redact.test.js +9 -0
- package/dist/rpc.test.js +77 -1
- package/dist/scripts/console.d.ts.map +1 -1
- package/dist/scripts/console.js +30 -2
- package/dist/scripts/deploy-stream.d.ts +181 -0
- package/dist/scripts/deploy-stream.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.js +332 -0
- package/dist/scripts/deploy-stream.test.d.ts +2 -0
- package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.test.js +845 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +16 -9
- package/dist/scripts/dev-server-cors.test.js +19 -1
- package/dist/scripts/dev-server-rpc.test.d.ts +2 -0
- package/dist/scripts/dev-server-rpc.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-rpc.test.js +157 -0
- package/dist/scripts/dev-server.d.ts +8 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +35 -8
- package/dist/scripts/sandbox.js +1 -1
- package/dist/telemetry/client.js +4 -4
- package/dist/telemetry/telemetry-send-worker.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -1
- package/src/cors.test.ts +96 -2
- package/src/cors.ts +59 -2
- package/src/errors.test.ts +29 -1
- package/src/hosting.test.ts +107 -0
- package/src/hosting.ts +27 -1
- package/src/lambda-handler.test.ts +71 -2
- package/src/lambda-handler.ts +4 -20
- package/src/redact.test.ts +12 -0
- package/src/redact.ts +4 -3
- package/src/rpc.test.ts +96 -1
- package/src/scripts/console.ts +29 -2
- package/src/scripts/deploy-stream.test.ts +1035 -0
- package/src/scripts/deploy-stream.ts +475 -0
- package/src/scripts/deploy.ts +18 -11
- package/src/scripts/dev-server-cors.test.ts +26 -1
- package/src/scripts/dev-server-rpc.test.ts +169 -0
- package/src/scripts/dev-server.ts +38 -8
- package/src/scripts/sandbox.ts +1 -1
- package/src/telemetry/client.ts +4 -4
- package/src/telemetry/telemetry-send-worker.ts +5 -0
- package/src/version.ts +1 -1
package/src/errors.test.ts
CHANGED
|
@@ -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', () => {
|
package/src/hosting.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/src/lambda-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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':
|
|
459
|
+
'Access-Control-Max-Age': CORS_MAX_AGE,
|
|
476
460
|
},
|
|
477
461
|
body: '',
|
|
478
462
|
};
|
package/src/redact.test.ts
CHANGED
|
@@ -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
|
|
135
|
-
*
|
|
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
|
+
});
|
package/src/scripts/console.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
59
|
+
openInBrowser(stackUrl);
|
|
33
60
|
});
|
|
34
61
|
}
|