serverless-ircd 0.7.0 → 0.8.0
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/.github/workflows/ci.yml +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are the primary red→green tests for the infrastructure stack:
|
|
5
5
|
* they assert the exact CloudFormation shape that `cdk synth` must
|
|
6
|
-
* produce —
|
|
6
|
+
* produce — six DynamoDB tables with the PLAN §4 schema, a single
|
|
7
7
|
* Node 24 Lambda wired to all three WebSocket routes, the API Gateway
|
|
8
8
|
* v2 WebSocket API, the two stack outputs, and a least-privilege guard
|
|
9
9
|
* that no IAM policy grants `Resource: '*'` or `Action: '*'`.
|
|
@@ -44,10 +44,44 @@ function makeTemplate(environmentName?: string): { app: App; stack: Stack; templ
|
|
|
44
44
|
return { app, stack, template };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Returns the synthesised CloudFormation `Parameters` section as a record. */
|
|
48
|
+
function parameters(
|
|
49
|
+
template: Template,
|
|
50
|
+
): Record<string, { Type: string; Default?: string; Description?: string }> {
|
|
51
|
+
const tmpl = template.toJSON() as {
|
|
52
|
+
Parameters?: Record<string, { Type: string; Default?: string; Description?: string }>;
|
|
53
|
+
};
|
|
54
|
+
return tmpl.Parameters ?? {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Finds the `AWS::Events::Rule` whose target `Arn` references a Lambda
|
|
59
|
+
* with the given logical-id prefix (CDK appends a hash suffix, e.g.
|
|
60
|
+
* `IrcSweeper7D484D9E`). Match is via `Fn::GetAtt: [<id>, Arn]`.
|
|
61
|
+
*/
|
|
62
|
+
function findRuleTargetingLambda(
|
|
63
|
+
template: Template,
|
|
64
|
+
fnIdPrefix: string,
|
|
65
|
+
):
|
|
66
|
+
| { Properties?: { ScheduleExpression?: unknown; Targets?: Array<{ Arn?: unknown }> } }
|
|
67
|
+
| undefined {
|
|
68
|
+
const rules = template.findResources('AWS::Events::Rule') as Record<
|
|
69
|
+
string,
|
|
70
|
+
{ Properties?: { Targets?: Array<{ Arn?: unknown }> } }
|
|
71
|
+
>;
|
|
72
|
+
return Object.values(rules).find((rule) =>
|
|
73
|
+
(rule.Properties?.Targets ?? []).some((t) => {
|
|
74
|
+
const getAtt = (t.Arn as { 'Fn::GetAtt'?: string[] } | undefined)?.['Fn::GetAtt'];
|
|
75
|
+
const id = Array.isArray(getAtt) ? getAtt[0] : undefined;
|
|
76
|
+
return typeof id === 'string' && id.startsWith(fnIdPrefix);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
describe('IrcAwsStack — DynamoDB tables', () => {
|
|
48
|
-
it('creates exactly
|
|
82
|
+
it('creates exactly six DynamoDB tables', () => {
|
|
49
83
|
const { template } = makeTemplate();
|
|
50
|
-
template.resourceCountIs('AWS::DynamoDB::Table',
|
|
84
|
+
template.resourceCountIs('AWS::DynamoDB::Table', 6);
|
|
51
85
|
});
|
|
52
86
|
|
|
53
87
|
it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
|
|
@@ -125,6 +159,21 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
125
159
|
KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
|
|
126
160
|
});
|
|
127
161
|
});
|
|
162
|
+
|
|
163
|
+
it('keys the Services table by composite pk/sk (String/String)', () => {
|
|
164
|
+
const { template } = makeTemplate();
|
|
165
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
166
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Services'),
|
|
167
|
+
AttributeDefinitions: [
|
|
168
|
+
{ AttributeName: 'pk', AttributeType: 'S' },
|
|
169
|
+
{ AttributeName: 'sk', AttributeType: 'S' },
|
|
170
|
+
],
|
|
171
|
+
KeySchema: [
|
|
172
|
+
{ AttributeName: 'pk', KeyType: 'HASH' },
|
|
173
|
+
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
128
177
|
});
|
|
129
178
|
|
|
130
179
|
describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
|
|
@@ -171,7 +220,7 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
|
|
|
171
220
|
const { template } = makeTemplate();
|
|
172
221
|
template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
|
|
173
222
|
ResponseParameters: {
|
|
174
|
-
'
|
|
223
|
+
'route.response.header.Sec-WebSocket-Protocol':
|
|
175
224
|
'integration.response.header.Sec-WebSocket-Protocol',
|
|
176
225
|
},
|
|
177
226
|
});
|
|
@@ -197,6 +246,163 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
|
|
|
197
246
|
});
|
|
198
247
|
});
|
|
199
248
|
|
|
249
|
+
describe('IrcAwsStack — API Gateway logging', () => {
|
|
250
|
+
/** Returns the synthesised CloudFormation `Parameters` section. */
|
|
251
|
+
function parameters(
|
|
252
|
+
template: Template,
|
|
253
|
+
): Record<
|
|
254
|
+
string,
|
|
255
|
+
{ Type: string; Default?: string; AllowedValues?: string[]; Description?: string }
|
|
256
|
+
> {
|
|
257
|
+
const tmpl = template.toJSON() as {
|
|
258
|
+
Parameters?: Record<
|
|
259
|
+
string,
|
|
260
|
+
{
|
|
261
|
+
Type: string;
|
|
262
|
+
Default?: string;
|
|
263
|
+
AllowedValues?: string[];
|
|
264
|
+
Description?: string;
|
|
265
|
+
}
|
|
266
|
+
>;
|
|
267
|
+
};
|
|
268
|
+
return tmpl.Parameters ?? {};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it('exposes ApiGatewayLoggingLevel as a configurable String parameter (default INFO)', () => {
|
|
272
|
+
const { template } = makeTemplate();
|
|
273
|
+
const p = parameters(template).ApiGatewayLoggingLevel;
|
|
274
|
+
expect(p).toBeDefined();
|
|
275
|
+
expect(p?.Type).toBe('String');
|
|
276
|
+
expect(p?.Default).toBe('INFO');
|
|
277
|
+
expect(typeof p?.Description).toBe('string');
|
|
278
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('restricts ApiGatewayLoggingLevel to OFF/INFO/ERROR', () => {
|
|
282
|
+
const { template } = makeTemplate();
|
|
283
|
+
expect(parameters(template).ApiGatewayLoggingLevel?.AllowedValues).toEqual([
|
|
284
|
+
'OFF',
|
|
285
|
+
'INFO',
|
|
286
|
+
'ERROR',
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('wires the stage execution logging level to the parameter (Ref) with data tracing on', () => {
|
|
291
|
+
const { template } = makeTemplate();
|
|
292
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
293
|
+
DefaultRouteSettings: {
|
|
294
|
+
LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
|
|
295
|
+
DataTraceEnabled: true,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('creates a CloudWatch LogGroup for API Gateway access logs', () => {
|
|
301
|
+
const { template } = makeTemplate();
|
|
302
|
+
template.resourceCountIs('AWS::Logs::LogGroup', 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('configures the API Gateway account CloudWatch role so execution logs can be written', () => {
|
|
306
|
+
const { template } = makeTemplate();
|
|
307
|
+
template.hasResourceProperties('AWS::ApiGateway::Account', {});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('wires the stage access log destination to the LogGroup', () => {
|
|
311
|
+
const { template } = makeTemplate();
|
|
312
|
+
const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
|
|
313
|
+
string,
|
|
314
|
+
{ Properties?: { AccessLogSettings?: { DestinationArn?: unknown } } }
|
|
315
|
+
>;
|
|
316
|
+
const stage = Object.values(stages)[0];
|
|
317
|
+
expect(stage?.Properties?.AccessLogSettings?.DestinationArn).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('makes the stage depend on the account CloudWatch role so logging can enable', () => {
|
|
321
|
+
// Without an explicit DependsOn, CloudFormation may apply the Stage's
|
|
322
|
+
// logging config before the AWS::ApiGateway::Account resource sets the
|
|
323
|
+
// account-wide CloudWatch role, and APIGW rejects the update:
|
|
324
|
+
// "CloudWatch Logs role ARN must be set in account settings to enable logging"
|
|
325
|
+
const { template } = makeTemplate();
|
|
326
|
+
const accountIds = Object.keys(template.findResources('AWS::ApiGateway::Account'));
|
|
327
|
+
expect(accountIds).toHaveLength(1);
|
|
328
|
+
const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
|
|
329
|
+
string,
|
|
330
|
+
{ DependsOn?: string[] }
|
|
331
|
+
>;
|
|
332
|
+
const stage = Object.values(stages)[0];
|
|
333
|
+
expect(stage?.DependsOn).toBeDefined();
|
|
334
|
+
expect(stage?.DependsOn?.some((id) => accountIds.includes(id))).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('makes the API Gateway Account depend on the logging role permissions policy', () => {
|
|
338
|
+
// APIGW validates the account CloudWatch role's CONTENT (trust AND the
|
|
339
|
+
// attached permissions) when it processes the AWS::ApiGateway::Account
|
|
340
|
+
// resource. CDK renders `addToPolicy` as a SEPARATE AWS::IAM::Policy
|
|
341
|
+
// resource, and the Account only references the role Arn, so without an
|
|
342
|
+
// explicit DependsOn CloudFormation can apply the Account after the Role
|
|
343
|
+
// is created but BEFORE the policy attaches. APIGW then assumes a
|
|
344
|
+
// permission-less role and rejects the update:
|
|
345
|
+
// "The role ARN does not have required permissions configured."
|
|
346
|
+
const { template } = makeTemplate();
|
|
347
|
+
const accounts = template.findResources('AWS::ApiGateway::Account') as Record<
|
|
348
|
+
string,
|
|
349
|
+
{ DependsOn?: string[] }
|
|
350
|
+
>;
|
|
351
|
+
expect(Object.keys(accounts)).toHaveLength(1);
|
|
352
|
+
const account = Object.values(accounts)[0];
|
|
353
|
+
expect(
|
|
354
|
+
account?.DependsOn,
|
|
355
|
+
'Account must depend on the logging role permissions policy',
|
|
356
|
+
).toBeDefined();
|
|
357
|
+
const policyIds = new Set(Object.keys(template.findResources('AWS::IAM::Policy')));
|
|
358
|
+
expect(
|
|
359
|
+
account?.DependsOn?.some((id) => policyIds.has(id)),
|
|
360
|
+
`Account.DependsOn ${JSON.stringify(account?.DependsOn)} must include the logging role policy`,
|
|
361
|
+
).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('grants the logging role CloudWatch Logs write access across ALL log groups', () => {
|
|
365
|
+
// APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
|
|
366
|
+
// (separate from this stack's access-log group), so the role must be
|
|
367
|
+
// able to write broadly — a single-log-group ARN scope is rejected by
|
|
368
|
+
// APIGW account-role validation. Defined inline (the
|
|
369
|
+
// AmazonAPIGatewayPushToCloudWatchLogs managed policy is not available
|
|
370
|
+
// in every partition); CDK renders the statement as a separate
|
|
371
|
+
// AWS::IAM::Policy attached to the role.
|
|
372
|
+
const { template } = makeTemplate();
|
|
373
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
|
|
374
|
+
const loggingRoleEntry = Object.entries(roles).find(([id]) =>
|
|
375
|
+
id.startsWith('ApiGatewayLoggingRole'),
|
|
376
|
+
);
|
|
377
|
+
expect(loggingRoleEntry, 'expected an ApiGatewayLoggingRole').toBeDefined();
|
|
378
|
+
const loggingRoleId = loggingRoleEntry?.[0];
|
|
379
|
+
const policies = template.findResources('AWS::IAM::Policy') as Record<
|
|
380
|
+
string,
|
|
381
|
+
{
|
|
382
|
+
Properties?: {
|
|
383
|
+
Roles?: Array<{ Ref?: string }>;
|
|
384
|
+
PolicyDocument?: {
|
|
385
|
+
Statement?: Array<{ Action?: string | string[]; Resource?: unknown }>;
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
>;
|
|
390
|
+
const attached = Object.values(policies).find((p) =>
|
|
391
|
+
(p.Properties?.Roles ?? []).some((r) => r.Ref === loggingRoleId),
|
|
392
|
+
);
|
|
393
|
+
expect(attached, 'expected an inline policy attached to the logging role').toBeDefined();
|
|
394
|
+
const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
|
|
395
|
+
const flat = (a: string | string[] | undefined): string[] =>
|
|
396
|
+
Array.isArray(a) ? a : a !== undefined ? [a] : [];
|
|
397
|
+
const actions = JSON.stringify(stmts.flatMap((s) => flat(s.Action)));
|
|
398
|
+
const resources = JSON.stringify(stmts.flatMap((s) => flat(s.Resource as string[])));
|
|
399
|
+
expect(actions).toContain('logs:PutLogEvents');
|
|
400
|
+
expect(actions).toContain('logs:CreateLogGroup');
|
|
401
|
+
// Broad scope — NOT narrowed to a single log-group ARN.
|
|
402
|
+
expect(resources).toContain('*');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
200
406
|
describe('IrcAwsStack — Lambda', () => {
|
|
201
407
|
it('creates exactly three Lambda functions (handler + sweeper + pingChecker)', () => {
|
|
202
408
|
const { template } = makeTemplate();
|
|
@@ -212,18 +418,13 @@ describe('IrcAwsStack — Lambda', () => {
|
|
|
212
418
|
});
|
|
213
419
|
|
|
214
420
|
describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
215
|
-
it('creates an EventBridge rule
|
|
421
|
+
it('creates an EventBridge rule targeting the sweeper Lambda', () => {
|
|
216
422
|
const { template } = makeTemplate();
|
|
217
|
-
template.
|
|
218
|
-
ScheduleExpression: 'rate(5 minutes)',
|
|
219
|
-
});
|
|
423
|
+
expect(findRuleTargetingLambda(template, 'IrcSweeper')).toBeDefined();
|
|
220
424
|
});
|
|
221
425
|
|
|
222
|
-
it('
|
|
426
|
+
it('grants EventBridge permission to invoke the sweeper', () => {
|
|
223
427
|
const { template } = makeTemplate();
|
|
224
|
-
template.hasResourceProperties('AWS::Events::Rule', {
|
|
225
|
-
ScheduleExpression: 'rate(5 minutes)',
|
|
226
|
-
});
|
|
227
428
|
// EventBridge needs permission to invoke each scheduled target. APIGW
|
|
228
429
|
// also creates one permission per route ($connect/$disconnect/$default),
|
|
229
430
|
// so the total is 5: 3 APIGW + 2 EventBridge (sweeper + pingChecker).
|
|
@@ -237,16 +438,14 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
|
237
438
|
// Both Lambda roles must scope their DynamoDB actions to the concrete
|
|
238
439
|
// table ARNs — the least-privilege suite below also asserts no '*'.
|
|
239
440
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
240
|
-
expect(Object.keys(tables)).toHaveLength(
|
|
441
|
+
expect(Object.keys(tables)).toHaveLength(6);
|
|
241
442
|
});
|
|
242
443
|
});
|
|
243
444
|
|
|
244
445
|
describe('IrcAwsStack — idle / PING checker', () => {
|
|
245
|
-
it('creates an EventBridge rule
|
|
446
|
+
it('creates an EventBridge rule targeting the pingChecker Lambda', () => {
|
|
246
447
|
const { template } = makeTemplate();
|
|
247
|
-
template.
|
|
248
|
-
ScheduleExpression: 'rate(1 minute)',
|
|
249
|
-
});
|
|
448
|
+
expect(findRuleTargetingLambda(template, 'IrcPingChecker')).toBeDefined();
|
|
250
449
|
});
|
|
251
450
|
|
|
252
451
|
it('creates exactly two EventBridge rules (sweeper + pingChecker)', () => {
|
|
@@ -294,6 +493,55 @@ describe('IrcAwsStack — idle / PING checker', () => {
|
|
|
294
493
|
});
|
|
295
494
|
});
|
|
296
495
|
|
|
496
|
+
describe('IrcAwsStack — schedule CloudFormation parameters', () => {
|
|
497
|
+
it('exposes SweeperScheduleExpression and PingCheckerScheduleExpression as parameters', () => {
|
|
498
|
+
const { template } = makeTemplate();
|
|
499
|
+
const keys = Object.keys(parameters(template));
|
|
500
|
+
expect(keys).toContain('SweeperScheduleExpression');
|
|
501
|
+
expect(keys).toContain('PingCheckerScheduleExpression');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('declares SweeperScheduleExpression as a String parameter with a non-empty description', () => {
|
|
505
|
+
const { template } = makeTemplate();
|
|
506
|
+
const p = parameters(template).SweeperScheduleExpression;
|
|
507
|
+
expect(p).toBeDefined();
|
|
508
|
+
expect(p?.Type).toBe('String');
|
|
509
|
+
expect(typeof p?.Description).toBe('string');
|
|
510
|
+
expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('declares PingCheckerScheduleExpression as a String parameter with a non-empty description', () => {
|
|
514
|
+
const { template } = makeTemplate();
|
|
515
|
+
const p = parameters(template).PingCheckerScheduleExpression;
|
|
516
|
+
expect(p).toBeDefined();
|
|
517
|
+
expect(p?.Type).toBe('String');
|
|
518
|
+
expect(typeof p?.Description).toBe('string');
|
|
519
|
+
expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('defaults SweeperScheduleExpression to rate(5 minutes) when the prop is omitted', () => {
|
|
523
|
+
const { template } = makeTemplate();
|
|
524
|
+
expect(parameters(template).SweeperScheduleExpression?.Default).toBe('rate(5 minutes)');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('defaults PingCheckerScheduleExpression to rate(1 minute) when the prop is omitted', () => {
|
|
528
|
+
const { template } = makeTemplate();
|
|
529
|
+
expect(parameters(template).PingCheckerScheduleExpression?.Default).toBe('rate(1 minute)');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('wires the sweeper rule ScheduleExpression via Ref to the parameter', () => {
|
|
533
|
+
const { template } = makeTemplate();
|
|
534
|
+
const rule = findRuleTargetingLambda(template, 'IrcSweeper');
|
|
535
|
+
expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'SweeperScheduleExpression' });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('wires the pingChecker rule ScheduleExpression via Ref to the parameter', () => {
|
|
539
|
+
const { template } = makeTemplate();
|
|
540
|
+
const rule = findRuleTargetingLambda(template, 'IrcPingChecker');
|
|
541
|
+
expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'PingCheckerScheduleExpression' });
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
297
545
|
describe('IrcAwsStack — stack outputs', () => {
|
|
298
546
|
it('emits a ConnectUrl output', () => {
|
|
299
547
|
const { template } = makeTemplate();
|
|
@@ -333,10 +581,45 @@ describe('IrcAwsStack — least-privilege IAM', () => {
|
|
|
333
581
|
|
|
334
582
|
it('never grants Resource: "*" in any IAM policy', () => {
|
|
335
583
|
const { template } = makeTemplate();
|
|
584
|
+
// Documented exception: the API Gateway account CloudWatch role MUST
|
|
585
|
+
// use Resource: '*'. APIGW writes execution logs to its OWN
|
|
586
|
+
// AWS/ApiGateway... log groups (not this stack's access-log group), and
|
|
587
|
+
// rejects a role scoped to a single log-group ARN. Identify the role's
|
|
588
|
+
// policy by the apigateway service principal so the exception is narrow.
|
|
589
|
+
const apigwRoleIds = new Set(
|
|
590
|
+
Object.entries(template.findResources('AWS::IAM::Role'))
|
|
591
|
+
.filter(([, r]) => {
|
|
592
|
+
const stmts =
|
|
593
|
+
(
|
|
594
|
+
r as {
|
|
595
|
+
Properties?: {
|
|
596
|
+
AssumeRolePolicyDocument?: {
|
|
597
|
+
Statement?: Array<{ Principal?: { Service?: string } }>;
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
).Properties?.AssumeRolePolicyDocument?.Statement ?? [];
|
|
602
|
+
return stmts.some((s) => s.Principal?.Service === 'apigateway.amazonaws.com');
|
|
603
|
+
})
|
|
604
|
+
.map(([id]) => id),
|
|
605
|
+
);
|
|
336
606
|
const policies = template.findResources('AWS::IAM::Policy');
|
|
337
607
|
for (const [, policy] of Object.entries(policies)) {
|
|
338
|
-
const
|
|
339
|
-
|
|
608
|
+
const props = (
|
|
609
|
+
policy as {
|
|
610
|
+
Properties: {
|
|
611
|
+
Roles?: Array<{ Ref?: string }>;
|
|
612
|
+
PolicyDocument: { Statement: unknown[] };
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
).Properties;
|
|
616
|
+
const isApigwLoggingPolicy = (props.Roles ?? []).some(
|
|
617
|
+
(r) => r.Ref !== undefined && apigwRoleIds.has(r.Ref),
|
|
618
|
+
);
|
|
619
|
+
if (isApigwLoggingPolicy) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const statements = props.PolicyDocument.Statement;
|
|
340
623
|
for (const stmt of statements) {
|
|
341
624
|
const resource = (stmt as { Resource?: unknown }).Resource;
|
|
342
625
|
if (Array.isArray(resource)) {
|
|
@@ -410,8 +693,8 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
|
|
|
410
693
|
{ Properties: { TableName: string } }
|
|
411
694
|
>,
|
|
412
695
|
).map((t) => t.Properties.TableName);
|
|
413
|
-
expect(stagingTables).toHaveLength(
|
|
414
|
-
expect(prodTables).toHaveLength(
|
|
696
|
+
expect(stagingTables).toHaveLength(6);
|
|
697
|
+
expect(prodTables).toHaveLength(6);
|
|
415
698
|
expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
|
|
416
699
|
});
|
|
417
700
|
|
|
@@ -424,17 +707,17 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
|
|
|
424
707
|
});
|
|
425
708
|
});
|
|
426
709
|
|
|
427
|
-
describe('IrcAwsStack — server identity
|
|
710
|
+
describe('IrcAwsStack — server identity CloudFormation parameters', () => {
|
|
428
711
|
/** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
|
|
429
712
|
function findFunction(
|
|
430
713
|
template: Template,
|
|
431
714
|
idPrefix: string,
|
|
432
715
|
): {
|
|
433
|
-
Properties?: { Environment?: { Variables?: Record<string,
|
|
716
|
+
Properties?: { Environment?: { Variables?: Record<string, unknown> } };
|
|
434
717
|
} {
|
|
435
718
|
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
436
719
|
string,
|
|
437
|
-
{ Properties?: { Environment?: { Variables?: Record<string,
|
|
720
|
+
{ Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
|
|
438
721
|
>;
|
|
439
722
|
const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
|
|
440
723
|
if (!found) {
|
|
@@ -443,70 +726,131 @@ describe('IrcAwsStack — server identity props (configurable deploy identity)',
|
|
|
443
726
|
return found[1];
|
|
444
727
|
}
|
|
445
728
|
|
|
446
|
-
it('
|
|
729
|
+
it('exposes ServerName and NetworkName as CloudFormation parameters', () => {
|
|
730
|
+
const { template } = makeTemplate();
|
|
731
|
+
const keys = Object.keys(parameters(template));
|
|
732
|
+
expect(keys).toContain('ServerName');
|
|
733
|
+
expect(keys).toContain('NetworkName');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('declares ServerName as a String parameter with a non-empty description', () => {
|
|
737
|
+
const { template } = makeTemplate();
|
|
738
|
+
const p = parameters(template).ServerName;
|
|
739
|
+
expect(p).toBeDefined();
|
|
740
|
+
expect(p?.Type).toBe('String');
|
|
741
|
+
expect(typeof p?.Description).toBe('string');
|
|
742
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('declares NetworkName as a String parameter with a non-empty description', () => {
|
|
746
|
+
const { template } = makeTemplate();
|
|
747
|
+
const p = parameters(template).NetworkName;
|
|
748
|
+
expect(p).toBeDefined();
|
|
749
|
+
expect(p?.Type).toBe('String');
|
|
750
|
+
expect(typeof p?.Description).toBe('string');
|
|
751
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('defaults ServerName to the non-placeholder fallback when the prop is omitted', () => {
|
|
755
|
+
const { template } = makeTemplate();
|
|
756
|
+
expect(parameters(template).ServerName?.Default).toBe('irc.localhost');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('defaults NetworkName to the non-placeholder fallback when the prop is omitted', () => {
|
|
760
|
+
const { template } = makeTemplate();
|
|
761
|
+
expect(parameters(template).NetworkName?.Default).toBe('ExampleNet');
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('flows an overridden serverName prop into the parameter Default', () => {
|
|
447
765
|
const app = new App();
|
|
448
766
|
const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
|
|
449
767
|
const template = Template.fromStack(stack);
|
|
450
|
-
expect(
|
|
451
|
-
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
452
|
-
).toBe('irc.my.net');
|
|
768
|
+
expect(parameters(template).ServerName?.Default).toBe('irc.my.net');
|
|
453
769
|
});
|
|
454
770
|
|
|
455
|
-
it('flows
|
|
771
|
+
it('flows an overridden networkName prop into the parameter Default', () => {
|
|
456
772
|
const app = new App();
|
|
457
|
-
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
773
|
+
const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
|
|
458
774
|
const template = Template.fromStack(stack);
|
|
459
|
-
|
|
775
|
+
expect(parameters(template).NetworkName?.Default).toBe('MyNet');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('wires SERVER_NAME on the handler Lambda via Ref to the parameter', () => {
|
|
779
|
+
const { template } = makeTemplate();
|
|
780
|
+
expect(
|
|
781
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
782
|
+
).toEqual({ Ref: 'ServerName' });
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('wires SERVER_NAME on the pingChecker Lambda via Ref to the parameter', () => {
|
|
786
|
+
const { template } = makeTemplate();
|
|
460
787
|
expect(
|
|
461
788
|
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
462
|
-
).
|
|
789
|
+
).toEqual({ Ref: 'ServerName' });
|
|
463
790
|
});
|
|
464
791
|
|
|
465
|
-
it('
|
|
466
|
-
const
|
|
467
|
-
const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
|
|
468
|
-
const template = Template.fromStack(stack);
|
|
792
|
+
it('wires NETWORK_NAME on the handler Lambda via Ref to the parameter', () => {
|
|
793
|
+
const { template } = makeTemplate();
|
|
469
794
|
expect(
|
|
470
795
|
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
471
|
-
).
|
|
796
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('wires NETWORK_NAME on the pingChecker Lambda via Ref to the parameter', () => {
|
|
800
|
+
const { template } = makeTemplate();
|
|
472
801
|
expect(
|
|
473
802
|
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
474
|
-
).
|
|
803
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
475
804
|
});
|
|
476
805
|
|
|
477
|
-
it('
|
|
806
|
+
it('wires SERVER_NAME and NETWORK_NAME on the NLB handler via Ref when tcpTlsDomainName is set', () => {
|
|
478
807
|
const app = new App();
|
|
479
|
-
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
808
|
+
const stack = new IrcAwsStack(app, 'TestStack', { tcpTlsDomainName: 'irc.example.com' });
|
|
480
809
|
const template = Template.fromStack(stack);
|
|
481
|
-
expect(
|
|
482
|
-
|
|
483
|
-
);
|
|
810
|
+
expect(
|
|
811
|
+
findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
812
|
+
).toEqual({ Ref: 'ServerName' });
|
|
813
|
+
expect(
|
|
814
|
+
findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
815
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
484
816
|
});
|
|
485
817
|
|
|
486
|
-
it('
|
|
818
|
+
it('injects server identity into the sweeper Lambda (shared config loader requires it)', () => {
|
|
819
|
+
// sweeperHandler calls buildDepsFromEnv() -> loadServerConfigFromLambdaEnv(),
|
|
820
|
+
// which hard-requires SERVER_NAME / NETWORK_NAME (and reads MOTD). Omitting
|
|
821
|
+
// them makes every scheduled sweeper invocation throw "serverName: Required"
|
|
822
|
+
// — so the sweeper MUST receive the same server-identity env vars as the
|
|
823
|
+
// handler, wired through the same CloudFormation parameters.
|
|
487
824
|
const { template } = makeTemplate();
|
|
488
825
|
expect(
|
|
489
|
-
findFunction(template, '
|
|
490
|
-
).
|
|
826
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
827
|
+
).toEqual({ Ref: 'ServerName' });
|
|
491
828
|
expect(
|
|
492
|
-
findFunction(template, '
|
|
493
|
-
).
|
|
829
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
830
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
831
|
+
expect(
|
|
832
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.MOTD,
|
|
833
|
+
).toBeDefined();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('joins motdLines with newline so the config loader splits them into separate lines', () => {
|
|
837
|
+
const app = new App();
|
|
838
|
+
const stack = new IrcAwsStack(app, 'TestStack', { motdLines: ['line one', 'line two'] });
|
|
839
|
+
const template = Template.fromStack(stack);
|
|
840
|
+
expect(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD).toBe(
|
|
841
|
+
'line one\nline two',
|
|
842
|
+
);
|
|
494
843
|
});
|
|
495
844
|
|
|
496
845
|
it('default MOTD is newline-joined so the loader yields multiple lines (no comma)', () => {
|
|
497
846
|
const { template } = makeTemplate();
|
|
498
847
|
const motd =
|
|
499
|
-
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD
|
|
848
|
+
(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD as
|
|
849
|
+
| string
|
|
850
|
+
| undefined) ?? '';
|
|
500
851
|
expect(motd.split('\n').length).toBeGreaterThanOrEqual(2);
|
|
501
852
|
expect(motd).not.toContain(',');
|
|
502
853
|
});
|
|
503
|
-
|
|
504
|
-
it('does not inject server identity into the sweeper Lambda', () => {
|
|
505
|
-
const { template } = makeTemplate();
|
|
506
|
-
expect(
|
|
507
|
-
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
508
|
-
).toBeUndefined();
|
|
509
|
-
});
|
|
510
854
|
});
|
|
511
855
|
|
|
512
856
|
describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-tcp-container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* catch-all response, now scoped to an explicit path so the root URL
|
|
19
19
|
* is free for the landing page).
|
|
20
20
|
* • everything else — falls through to the `[assets]` binding, which
|
|
21
|
-
* serves the Kiwi SPA at `/
|
|
21
|
+
* serves the Kiwi SPA at `/webclient/` and the landing page at `/` from
|
|
22
22
|
* `apps/web/dist`. The asset layer is configured in `wrangler.toml`.
|
|
23
23
|
*
|
|
24
24
|
* The DO classes are re-exported so `wrangler.toml`'s
|
|
@@ -47,8 +47,8 @@ export type { Env };
|
|
|
47
47
|
/**
|
|
48
48
|
* Production {@link Env} for the worker entry. Extends the cf-adapter
|
|
49
49
|
* {@link Env} with the `ASSETS` binding (the static-asset `Fetcher` for
|
|
50
|
-
* `/
|
|
51
|
-
* asset-agnostic; only the edge entry knows about the asset layer.
|
|
50
|
+
* `/webclient/` SPA + `/` landing page). The cf-adapter's internal types
|
|
51
|
+
* stay asset-agnostic; only the edge entry knows about the asset layer.
|
|
52
52
|
*/
|
|
53
53
|
export interface WorkerEnv extends Env {
|
|
54
54
|
/**
|
|
@@ -106,7 +106,7 @@ const ORIGIN_UNCONFIGURED_BODY =
|
|
|
106
106
|
* 1. WebSocket upgrade → CSWSH `Origin` check, then per-connection
|
|
107
107
|
* `ConnectionDO`.
|
|
108
108
|
* 2. `GET /health` → plaintext liveness response.
|
|
109
|
-
* 3. everything else → `env.ASSETS` (Kiwi SPA at `/
|
|
109
|
+
* 3. everything else → `env.ASSETS` (Kiwi SPA at `/webclient/`, landing at `/`).
|
|
110
110
|
*
|
|
111
111
|
* The connection id is allocated here via `crypto.randomUUID()` (the
|
|
112
112
|
* Web Crypto API is sync-available in Workers for RFC-4122 v4 UUIDs).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Test-only fixture mirroring the real `apps/web/dist/webclient/index.html`
|
|
4
|
+
produced by the Kiwi SPA build (`pnpm --filter web build`). The cf-worker
|
|
5
|
+
smoke suite points its `[assets]` binding here so the worker's fall-through
|
|
6
|
+
routing (WS → ConnectionDO, /health → plaintext, else → ASSETS) is exercised
|
|
7
|
+
without depending on the (slow, submodule-heavy) Kiwi build. Production
|
|
8
|
+
points at `apps/web/dist`; this fixture only stands in for the test run.
|
|
9
|
+
-->
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8" />
|
|
13
|
+
<title>KiwiIRC</title>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="app"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|