serverless-ircd 0.9.0 → 0.10.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.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,10 +1,9 @@
1
1
  /**
2
- * End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
2
+ * End-to-end SASL PLAIN through ConnectionDO with D1-backed services.
3
3
  *
4
- * Verifies the full precedence matrix:
5
- * - D1 has rows → table is authoritative (900/903 for seeded account,
6
- * 904 for the env-seed account which the table shadows).
7
- * - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
4
+ * Verifies the services-store credential path: an account seeded into the
5
+ * `nickserv_accounts` table (via `ServicesStore.registerNick`) verifies
6
+ * end-to-end through `AUTHENTICATE PLAIN`.
8
7
  *
9
8
  * Each test file gets its own isolated D1 database from miniflare, so the
10
9
  * table state set up here does not leak into `connection-do.test.ts`.
@@ -12,7 +11,7 @@
12
11
 
13
12
  import { env } from 'cloudflare:test';
14
13
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
- import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
14
+ import { CREATE_SERVICES_TABLES_SQL } from '../src/d1-services-store';
16
15
 
17
16
  declare global {
18
17
  namespace Cloudflare {
@@ -107,16 +106,56 @@ async function authenticatePlain(
107
106
  client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
108
107
  }
109
108
 
110
- describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
109
+ /**
110
+ * Seeds a NickServ registration directly into the D1 services table by
111
+ * driving a first connection through `PRIVMSG NickServ :REGISTER`. Returns
112
+ * once the registration NOTICE is observed.
113
+ */
114
+ async function seedViaNickservRegister(
115
+ connId: string,
116
+ nick: string,
117
+ password: string,
118
+ email: string,
119
+ ): Promise<IrcWsClient> {
120
+ const client = await connect(connId);
121
+ client.send(`NICK ${nick}\r\n`);
122
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
123
+ await client.waitForMessage((l) => l.includes(' 001 '));
124
+ client.send(`PRIVMSG NickServ :REGISTER ${password} ${email}\r\n`);
125
+ await client.waitForMessage((l) => l.includes('NickServ') && l.includes('registered'));
126
+ return client;
127
+ }
128
+
129
+ describe('ConnectionDO — SASL PLAIN with D1-backed services', () => {
111
130
  beforeEach(async () => {
112
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
131
+ for (const sql of CREATE_SERVICES_TABLES_SQL) {
132
+ await env.ACCOUNTS_DB.exec(sql);
133
+ }
113
134
  });
114
135
  afterEach(async () => {
115
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
136
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
137
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_channels');
138
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_access');
139
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_levels');
140
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_vhosts');
141
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_pending_vhosts');
142
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS memoserv_memos');
143
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS read_markers');
144
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_akills');
145
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_jupes');
116
146
  });
117
147
 
118
- it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
119
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
148
+ it('completes SASL PLAIN with 900/903 for a NickServ-registered account', async () => {
149
+ // Seed alice via NickServ REGISTER on a first connection.
150
+ const seeder = await seedViaNickservRegister(
151
+ 'conn-d1-sasl-seed',
152
+ 'd1-alice',
153
+ 'd1-s3cret',
154
+ 'd1@example.com',
155
+ );
156
+ await seeder.close();
157
+
158
+ // A second connection speaking SASL PLAIN against the registered nick.
120
159
  const client = await connect('conn-d1-sasl-1');
121
160
  await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
122
161
  await client.waitForMessage((l) => l.includes(' 900 '));
@@ -125,42 +164,27 @@ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
125
164
  await client.close();
126
165
  });
127
166
 
128
- it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
129
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
167
+ it('rejects SASL PLAIN with 904 for a wrong password', async () => {
168
+ const seeder = await seedViaNickservRegister(
169
+ 'conn-d1-sasl-seed2',
170
+ 'd1-bob',
171
+ 'd1-s3cret',
172
+ 'd1@example.com',
173
+ );
174
+ await seeder.close();
175
+
130
176
  const client = await connect('conn-d1-sasl-2');
131
- await authenticatePlain(client, 'd1-alice', 'wrong');
177
+ await authenticatePlain(client, 'd1-bob', 'wrong');
132
178
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
133
179
  expect(fail).toContain(' 904 ');
134
180
  await client.close();
135
181
  });
136
182
 
137
- it('table shadows the env-seed account (table wins)', async () => {
138
- // SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
139
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
183
+ it('rejects SASL PLAIN with 904 for an unknown nick', async () => {
140
184
  const client = await connect('conn-d1-sasl-3');
141
- // env-seed account does NOT verify (table is authoritative).
142
- await authenticatePlain(client, 'cf-sasl', 's3cret');
185
+ await authenticatePlain(client, 'nobody', 'whatever');
143
186
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
144
187
  expect(fail).toContain(' 904 ');
145
188
  await client.close();
146
189
  });
147
190
  });
148
-
149
- describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
150
- // The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
151
- beforeEach(async () => {
152
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
153
- });
154
- afterEach(async () => {
155
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
156
- });
157
-
158
- it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
159
- const client = await connect('conn-d1-sasl-fallback');
160
- await authenticatePlain(client, 'cf-sasl', 's3cret');
161
- await client.waitForMessage((l) => l.includes(' 900 '));
162
- const success = await client.waitForMessage((l) => l.includes(' 903 '));
163
- expect(success).toContain(' 903 ');
164
- await client.close();
165
- });
166
- });
@@ -15,6 +15,7 @@ import {
15
15
  CREATE_SERVICES_TABLES_SQL,
16
16
  D1ServicesStore,
17
17
  loadD1ServicesStore,
18
+ safeSelect,
18
19
  } from '../src/d1-services-store';
19
20
 
20
21
  declare global {
@@ -194,6 +195,18 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
194
195
  expect(reloaded?.getNickEnforce('alice')).toBe('kill');
195
196
  });
196
197
 
198
+ it('persists SET PASSWORD and round-trips the new credential', async () => {
199
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
200
+ store?.registerNick('alice', 'hunter2', 'a@x');
201
+ store?.setNickPassword('alice', 'newpassword');
202
+ await store?.flush();
203
+
204
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
205
+ // The new password verifies against the reloaded store; the old one no longer does.
206
+ expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
207
+ expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
208
+ });
209
+
197
210
  it('drops a NickServ registration after flush + reload', async () => {
198
211
  const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
199
212
  store?.registerNick('alice', 'pw', 'a@x');
@@ -524,14 +537,18 @@ describe('D1ServicesStore — tolerance', () => {
524
537
  expect(snap.jupes).toEqual([]);
525
538
  });
526
539
 
527
- it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
528
- // Recreate the two all-TEXT tables without NOT NULL so a NULL field can
540
+ it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
541
+ // Recreate the all-TEXT tables without NOT NULL so a NULL field can
529
542
  // survive — exercises the typeof-string defensive skip for the loaders
530
543
  // whose INTEGER-corruption path is unreachable under SQLite affinity.
531
544
  await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
532
545
  await env.ACCOUNTS_DB.exec(
533
546
  'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
534
547
  );
548
+ await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
549
+ await env.ACCOUNTS_DB.exec(
550
+ 'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
551
+ );
535
552
  await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
536
553
  await env.ACCOUNTS_DB.exec(
537
554
  'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
@@ -541,6 +558,11 @@ describe('D1ServicesStore — tolerance', () => {
541
558
  )
542
559
  .bind('bad#v', null, 'cloak')
543
560
  .run();
561
+ await env.ACCOUNTS_DB.prepare(
562
+ 'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
563
+ )
564
+ .bind('bad#pv', 'someone', null)
565
+ .run();
544
566
  await env.ACCOUNTS_DB.prepare(
545
567
  'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
546
568
  )
@@ -552,6 +574,7 @@ describe('D1ServicesStore — tolerance', () => {
552
574
  if (!store) throw new Error('services store failed to load');
553
575
  const snap = store.snapshot();
554
576
  expect(snap.vhosts).toEqual([]);
577
+ expect(snap.pendingVhosts).toEqual([]);
555
578
  expect(snap.readMarkers).toEqual([]);
556
579
  });
557
580
 
@@ -566,6 +589,34 @@ describe('D1ServicesStore — tolerance', () => {
566
589
  });
567
590
  });
568
591
 
592
+ // ---------------------------------------------------------------------------
593
+ // safeSelect — defensive catch block (real D1 bindings don't throw on
594
+ // existing-table queries; the catch arm is unreachable through integration).
595
+ // ---------------------------------------------------------------------------
596
+
597
+ describe('safeSelect — defensive catch block', () => {
598
+ it('returns [] and logs when the underlying D1 query rejects', async () => {
599
+ // A misbehaving binding (transient unavailability, schema drift) would
600
+ // otherwise brick NickServ for the whole connection (the snapshot is
601
+ // cached for life). The catch arm logs to wrangler tail and degrades
602
+ // to an empty result.
603
+ const stub = {
604
+ prepare(): { all(): Promise<never> } {
605
+ return {
606
+ all: async () => {
607
+ throw new Error('simulated D1 outage');
608
+ },
609
+ };
610
+ },
611
+ };
612
+ const result = await safeSelect<{ x: unknown }>(
613
+ stub as unknown as Parameters<typeof safeSelect>[0],
614
+ 'SELECT * FROM nicks',
615
+ );
616
+ expect(result).toEqual([]);
617
+ });
618
+ });
619
+
569
620
  function emptySnapshot(): ServicesSnapshot {
570
621
  return {
571
622
  nicks: [],
@@ -306,4 +306,29 @@ describe('deserialize — migrate integration', () => {
306
306
  expect(state.userModes.wallops).toBe(true);
307
307
  expect(state.caps).toEqual(new Set(['server-time']));
308
308
  });
309
+
310
+ it('coalesces an explicit `secure: null` to false via the ?? fallback', () => {
311
+ // `migrate` only backfills `secure` when the key is absent (=== undefined);
312
+ // an explicit null survives and the `?? false` arm in `deserialize` must
313
+ // catch it. Mirrors a hand-edited storage row where someone wrote null.
314
+ const rec = {
315
+ version: 1,
316
+ id: 'c',
317
+ registration: 'registered' as const,
318
+ capNegotiating: false,
319
+ secure: null,
320
+ caps: [],
321
+ joinedChannels: [],
322
+ userModes: {
323
+ invisible: false,
324
+ oper: false,
325
+ wallops: false,
326
+ serverNotices: false,
327
+ tls: false,
328
+ },
329
+ lastSeen: 0,
330
+ connectedSince: 0,
331
+ };
332
+ expect(deserialize(rec).secure).toBe(false);
333
+ });
309
334
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure helpers for the one-shot `accounts` → `nickserv_accounts` /
3
+ * `Services` migration (`tools/migrate-accounts-to-services.ts`).
4
+ *
5
+ * Lives in `irc-core` so both adapter test suites can import the helpers
6
+ * without a cross-adapter dependency. The CLI entry point (wrangler /
7
+ * DynamoDB IO) lives in the tools script; these helpers are side-effect-
8
+ * free and fully unit-testable.
9
+ */
10
+
11
+ import { caseFold } from './case-fold.js';
12
+ import { hashAccountCredential } from './credential-hashing.js';
13
+
14
+ /**
15
+ * DDL for the `nickserv_accounts` table (the migration target). Inlined
16
+ * rather than imported from `@serverless-ircd/cf-adapter` so the helpers
17
+ * are testable from any package. Kept in sync with
18
+ * `cf-adapter/src/d1-services-store.ts`.
19
+ */
20
+ export const MIGRATION_NICKSERV_ACCOUNTS_SQL =
21
+ "CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
22
+
23
+ /** Parsed `SASL_ACCOUNTS` env var entry. */
24
+ export interface ParsedSaslAccount {
25
+ username: string;
26
+ password: string;
27
+ }
28
+
29
+ /** A raw legacy `accounts` row read from D1 or DynamoDB. */
30
+ export interface LegacyAccountRow {
31
+ account: string;
32
+ algorithm: string;
33
+ salt: string;
34
+ hash: string;
35
+ }
36
+
37
+ /** Parses the `SASL_ACCOUNTS` env var into credential entries. */
38
+ export function parseSaslAccountsForMigration(raw: string | undefined): ParsedSaslAccount[] {
39
+ if (raw === undefined || raw.length === 0) return [];
40
+ const out: ParsedSaslAccount[] = [];
41
+ for (const line of raw.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (trimmed.length === 0) continue;
44
+ const sep = trimmed.indexOf(':');
45
+ if (sep <= 0) continue;
46
+ const username = trimmed.slice(0, sep);
47
+ const password = trimmed.slice(sep + 1);
48
+ if (username.length === 0 || password.length === 0) continue;
49
+ out.push({ username, password });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Builds the set of `INSERT OR IGNORE` SQL statements that backfill the
56
+ * legacy `accounts` rows plus the `SASL_ACCOUNTS` env-seed entries into
57
+ * `nickserv_accounts`. Pure (no IO).
58
+ *
59
+ * - Legacy rows are copied verbatim (the pre-hashed scrypt credential is
60
+ * preserved; the plaintext is NOT re-hashed). The services row carries
61
+ * `cert_subjects = '[]'`, email `''`, enforce `'none'`, `created_at 0`.
62
+ * - `SASL_ACCOUNTS` env-seed entries are hashed fresh (the env var carries
63
+ * plaintext passwords). Only entries whose username is NOT already
64
+ * present in the legacy rows are emitted (legacy rows win over the env
65
+ * seed).
66
+ * - `INSERT OR IGNORE` guarantees idempotency: an existing
67
+ * `nickserv_accounts` row (registered via NickServ or a prior seed) is
68
+ * never clobbered.
69
+ *
70
+ * @returns The array of executable SQL statements (CREATE TABLE + INSERTs).
71
+ */
72
+ export function buildMigrationSql(
73
+ legacyRows: ReadonlyArray<LegacyAccountRow>,
74
+ saslAccountsRaw: string | undefined,
75
+ ): string[] {
76
+ const statements: string[] = [MIGRATION_NICKSERV_ACCOUNTS_SQL];
77
+
78
+ const seen = new Set<string>();
79
+
80
+ for (const row of legacyRows) {
81
+ const nickKey = caseFold('rfc1459', row.account);
82
+ seen.add(nickKey);
83
+ statements.push(buildInsertOrIgnore(row.account, row));
84
+ }
85
+
86
+ for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
87
+ const nickKey = caseFold('rfc1459', acct.username);
88
+ if (seen.has(nickKey)) continue;
89
+ seen.add(nickKey);
90
+ const cred = hashAccountCredential(acct.username, acct.password);
91
+ statements.push(buildInsertOrIgnore(acct.username, cred));
92
+ }
93
+
94
+ return statements;
95
+ }
96
+
97
+ function buildInsertOrIgnore(
98
+ username: string,
99
+ cred: { algorithm: string; salt: string; hash: string },
100
+ ): string {
101
+ const nickKey = caseFold('rfc1459', username);
102
+ return `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(cred.algorithm)}', '${sqlEscape(cred.salt)}', '${sqlEscape(cred.hash)}')`;
103
+ }
104
+
105
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
106
+ function sqlEscape(value: string): string {
107
+ return value.replace(/'/g, "''");
108
+ }
109
+
110
+ /**
111
+ * Builds the DynamoDB `PutItem` input for a legacy row, targeting the
112
+ * `Services` table's `NICK:<fold>` / `#` item. Pure.
113
+ *
114
+ * The caller wraps this in a conditional PutItem
115
+ * (`ConditionExpression: attribute_not_exists(pk)`) so an existing nick
116
+ * registration is never clobbered.
117
+ */
118
+ export function buildServicesPutItem(
119
+ row: { account: string; algorithm: string; salt: string; hash: string },
120
+ tableName: string,
121
+ ): { TableName: string; Item: Record<string, unknown> } {
122
+ const fold = caseFold('rfc1459', row.account);
123
+ return {
124
+ TableName: tableName,
125
+ Item: {
126
+ pk: `NICK:${fold}`,
127
+ sk: '#',
128
+ type: 'nick',
129
+ nick: row.account,
130
+ account: row.account,
131
+ email: '',
132
+ createdAt: 0,
133
+ enforce: 'none',
134
+ certSubjects: [],
135
+ algorithm: row.algorithm,
136
+ salt: row.salt,
137
+ hash: row.hash,
138
+ },
139
+ };
140
+ }
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * {@link passBasedAccountAuth} is the legacy IRC convention of carrying
15
15
  * NickServ credentials in `PASS <nick>:<password>` at registration; it
16
- * resolves at registration completion via the injected
17
- * {@link AccountStore} and, on success, delegates to
16
+ * resolves at registration completion via the bound
17
+ * {@link ServicesStore} (`services.verifyNick` the same single
18
+ * credential home NickServ `REGISTER`, SASL PLAIN, and NickServ
19
+ * `IDENTIFY` consult) and, on success, delegates to
18
20
  * {@link applyAccountSuccess}.
19
21
  */
20
22
 
@@ -129,17 +131,20 @@ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectTy
129
131
  * in `passReducer` (reading the `PASS` param directly), so the two entry
130
132
  * points share one verify+success path and never diverge.
131
133
  *
132
- * Returns the account-login success effects (`900`/`903`/`+r`/read-marker
134
+ * Credentials are verified against the bound {@link ServicesStore} via
135
+ * `services.verifyNick` — the same single credential home NickServ
136
+ * `REGISTER`, SASL PLAIN, and NickServ `IDENTIFY` consult. Returns the
137
+ * account-login success effects (`900`/`903`/`+r`/read-marker
133
138
  * seeding/away replay/memo delivery/`account-notify`), or `[]` for every
134
139
  * other outcome:
135
140
  * - already identified (`state.account` set, e.g. via SASL) → no-op;
136
141
  * - no `<nick>:<password>` form (bare value or undefined) → no-op;
137
142
  * - payload nick does not match `state.nick` (when set) → no-op;
138
- * - no {@link AccountStore} bound → no-op;
143
+ * - no {@link ServicesStore} bound → no-op;
139
144
  * - verify failure (unknown nick or wrong password) → no-op.
140
145
  *
141
146
  * A failed verify is followed by a verify against a fixed dummy entry so
142
- * the failure path performs the same AccountStore work whether the payload
147
+ * the failure path performs the same scrypt work whether the payload
143
148
  * nick was unknown or the password was wrong — the observable outcome
144
149
  * (no numerics, no disconnect, no state change) is identical for both
145
150
  * cases, giving no information to a remote attacker.
@@ -153,24 +158,16 @@ export function attemptPassAccountAuth(
153
158
  const parsed = parsePassAccountAttempt(attempt);
154
159
  if (parsed === null) return [];
155
160
  if (state.nick !== undefined && state.nick !== parsed.nick) return [];
156
- const store = ctx.accounts;
157
- if (store === undefined) return [];
158
- const result = store.verify('PLAIN', {
159
- kind: 'PLAIN',
160
- username: parsed.nick,
161
- password: parsed.password,
162
- });
161
+ const services = ctx.services;
162
+ if (services === undefined) return [];
163
+ const result = services.verifyNick(parsed.nick, parsed.password);
163
164
  if (result.ok) {
164
165
  return applyAccountSuccess(state, ctx, result.account);
165
166
  }
166
167
  // Equalise timing: perform a verify against a fixed dummy entry so a
167
- // failed PASS-auth always costs one extra AccountStore call regardless
168
- // of whether the payload nick was unknown or the password was wrong.
169
- store.verify('PLAIN', {
170
- kind: 'PLAIN',
171
- username: PASS_AUTH_DUMMY_NICK,
172
- password: PASS_AUTH_DUMMY_PASSWORD,
173
- });
168
+ // failed PASS-auth always costs one extra scrypt verify regardless of
169
+ // whether the payload nick was unknown or the password was wrong.
170
+ services.verifyNick(PASS_AUTH_DUMMY_NICK, PASS_AUTH_DUMMY_PASSWORD);
174
171
  return [];
175
172
  }
176
173
 
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
21
21
  * emitted as {@link Effect}s. The services store is synchronous (mirrors
22
- * the {@link AccountStore} / {@link MessageStore} pattern).
22
+ * the {@link MessageStore} pattern).
23
23
  */
24
24
 
25
25
  import { caseFold } from '../case-fold.js';