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
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Migration script: backfills rows from the legacy `accounts` / `Accounts`
4
+ * table into the unified `nickserv_accounts` / `Services` table BEFORE the
5
+ * build that drops the legacy table is deployed.
6
+ *
7
+ * The migration is:
8
+ * - **One-shot**: run once per environment (staging then prod), then the
9
+ * new build lands.
10
+ * - **Idempotent**: re-running is a no-op — `INSERT OR IGNORE` (D1) /
11
+ * conditional PutItem (DynamoDB) never clobbers an existing services
12
+ * row.
13
+ * - **Never-overwrite**: a nick already registered in the services table
14
+ * (via NickServ REGISTER or a prior seed) wins; the legacy row's
15
+ * credential is NOT copied over it.
16
+ * - **Cert-subject carry-through**: the legacy `accounts` / `Accounts`
17
+ * table has no `cert_subjects` column. The script reads the still-
18
+ * deployed `SASL_ACCOUNTS` env var at run time; any account declared
19
+ * there is registered (if not already present) so the env-seed
20
+ * survives the table drop. The env-var format (`username:password`)
21
+ * does NOT carry cert subjects, so `cert_subjects` defaults to `'[]'`
22
+ * for every migrated row — matching today's effective behavior (SASL
23
+ * EXTERNAL only worked against the static `InMemoryAccountStore`, never
24
+ * against the hashed `accounts`/`Accounts` table).
25
+ *
26
+ * Usage (Cloudflare D1):
27
+ * node --import tsx tools/migrate-accounts-to-services.ts \
28
+ * --platform cf \
29
+ * --database serverless-ircd-accounts \
30
+ * --env staging \
31
+ * --remote
32
+ *
33
+ * Usage (AWS DynamoDB):
34
+ * node --import tsx tools/migrate-accounts-to-services.ts \
35
+ * --platform aws \
36
+ * --accounts-table StagingAccounts \
37
+ * --services-table StagingServices \
38
+ * --region us-east-1
39
+ *
40
+ * The `SASL_ACCOUNTS` env var is read at run time in both modes so any
41
+ * operator-declared seed accounts are carried into the services table.
42
+ */
43
+
44
+ // ============================================================================
45
+ // Pure helpers (re-exported from irc-core for testability)
46
+ // ============================================================================
47
+
48
+ export {
49
+ type LegacyAccountRow,
50
+ type ParsedSaslAccount,
51
+ buildMigrationSql,
52
+ buildServicesPutItem,
53
+ parseSaslAccountsForMigration,
54
+ } from '@serverless-ircd/irc-core';
55
+ import { caseFold } from '@serverless-ircd/irc-core';
56
+
57
+ // ============================================================================
58
+ // CLI entry point (only runs when executed directly via `node --import tsx`)
59
+ // ============================================================================
60
+
61
+ async function main(): Promise<void> {
62
+ const { execFileSync } = await import('node:child_process');
63
+ const { parseArgs } = await import('node:util');
64
+ const { values } = parseArgs({
65
+ options: {
66
+ platform: { type: 'string' },
67
+ database: { type: 'string', default: undefined },
68
+ env: { type: 'string', default: undefined },
69
+ remote: { type: 'boolean', default: false },
70
+ 'accounts-table': { type: 'string', default: undefined },
71
+ 'services-table': { type: 'string', default: undefined },
72
+ region: { type: 'string', default: undefined },
73
+ endpoint: { type: 'string', default: undefined },
74
+ },
75
+ });
76
+
77
+ const SASL_ACCOUNTS_RAW = process.env.SASL_ACCOUNTS;
78
+
79
+ if (values.platform === 'cf') {
80
+ await runCfMigration(values, SASL_ACCOUNTS_RAW, execFileSync);
81
+ } else if (values.platform === 'aws') {
82
+ await runAwsMigration(values, SASL_ACCOUNTS_RAW);
83
+ } else {
84
+ console.error(
85
+ 'Usage: migrate-accounts-to-services --platform cf|aws [platform-specific flags]',
86
+ );
87
+ console.error(' cf: --database <d1-name> [--env staging] [--remote]');
88
+ console.error(
89
+ ' aws: --accounts-table <name> --services-table <name> [--region <r>] [--endpoint <url>]',
90
+ );
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ type CfValues = {
96
+ database?: string;
97
+ env?: string;
98
+ remote?: boolean;
99
+ };
100
+
101
+ async function runCfMigration(
102
+ values: CfValues,
103
+ saslAccountsRaw: string | undefined,
104
+ execFileSync: typeof import('node:child_process').execFileSync,
105
+ ): Promise<void> {
106
+ if (values.database === undefined) {
107
+ console.error('CF migration requires --database <d1-name>');
108
+ process.exit(1);
109
+ }
110
+ const selectArgs = [
111
+ 'd1',
112
+ 'execute',
113
+ values.database,
114
+ '--command',
115
+ 'SELECT account, algorithm, salt, hash FROM accounts',
116
+ '--json',
117
+ ];
118
+ if (values.env !== undefined) selectArgs.push('--env', values.env);
119
+ if (values.remote) selectArgs.push('--remote');
120
+
121
+ let legacyRows: LegacyAccountRow[] = [];
122
+ try {
123
+ const output = execFileSync('npx', ['wrangler', ...selectArgs], {
124
+ encoding: 'utf-8',
125
+ stdio: ['pipe', 'pipe', 'inherit'],
126
+ });
127
+ const parsed = JSON.parse(output) as Array<{ results?: Array<Record<string, unknown>> }>;
128
+ legacyRows = parsed
129
+ .flatMap((r) => r.results ?? [])
130
+ .filter(
131
+ (r): r is Record<string, string> =>
132
+ typeof r.account === 'string' &&
133
+ typeof r.algorithm === 'string' &&
134
+ typeof r.salt === 'string' &&
135
+ typeof r.hash === 'string',
136
+ )
137
+ .map((r) => ({
138
+ account: r.account,
139
+ algorithm: r.algorithm,
140
+ salt: r.salt,
141
+ hash: r.hash,
142
+ }));
143
+ } catch (err) {
144
+ console.error(`Could not read legacy accounts table: ${String(err)}`);
145
+ console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
146
+ }
147
+
148
+ const statements = buildMigrationSql(legacyRows, saslAccountsRaw);
149
+ for (const sql of statements) {
150
+ const execArgs = ['d1', 'execute', values.database, '--command', sql];
151
+ if (values.env !== undefined) execArgs.push('--env', values.env);
152
+ if (values.remote) execArgs.push('--remote');
153
+ execFileSync('npx', ['wrangler', ...execArgs], { stdio: 'inherit' });
154
+ }
155
+ console.log(
156
+ `CF migration complete: ${statements.length - 1} statement(s) executed ` +
157
+ `(${legacyRows.length} legacy rows + env-seed carry-through).`,
158
+ );
159
+ }
160
+
161
+ type AwsValues = {
162
+ 'accounts-table'?: string;
163
+ 'services-table'?: string;
164
+ region?: string;
165
+ endpoint?: string;
166
+ };
167
+
168
+ async function runAwsMigration(
169
+ values: AwsValues,
170
+ saslAccountsRaw: string | undefined,
171
+ ): Promise<void> {
172
+ if (values['accounts-table'] === undefined || values['services-table'] === undefined) {
173
+ console.error('AWS migration requires --accounts-table and --services-table');
174
+ process.exit(1);
175
+ }
176
+ const { PutCommand, ScanCommand } = await import('@aws-sdk/lib-dynamodb');
177
+ const { createDynamoDocumentClient, loadDynamoServicesStore } = await import(
178
+ '@serverless-ircd/aws-adapter'
179
+ );
180
+ const { SystemClock } = await import('@serverless-ircd/irc-core');
181
+
182
+ const clientOpts: Record<string, string> = {};
183
+ if (values.region !== undefined) clientOpts.region = values.region;
184
+ if (values.endpoint !== undefined) clientOpts.endpoint = values.endpoint;
185
+ const docClient = createDynamoDocumentClient(clientOpts);
186
+
187
+ const legacyRows: LegacyAccountRow[] = [];
188
+ try {
189
+ let exclusiveStartKey: Record<string, unknown> | undefined;
190
+ do {
191
+ const result = await docClient.send(
192
+ new ScanCommand({
193
+ TableName: values['accounts-table'],
194
+ ExclusiveStartKey: exclusiveStartKey,
195
+ }),
196
+ );
197
+ for (const item of result.Items ?? []) {
198
+ if (
199
+ typeof item.account === 'string' &&
200
+ typeof item.algorithm === 'string' &&
201
+ typeof item.salt === 'string' &&
202
+ typeof item.hash === 'string'
203
+ ) {
204
+ legacyRows.push({
205
+ account: item.account,
206
+ algorithm: item.algorithm,
207
+ salt: item.salt,
208
+ hash: item.hash,
209
+ });
210
+ }
211
+ }
212
+ exclusiveStartKey = result.LastEvaluatedKey;
213
+ } while (exclusiveStartKey !== undefined);
214
+ } catch (err) {
215
+ console.error(`Could not scan legacy Accounts table: ${String(err)}`);
216
+ console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
217
+ }
218
+
219
+ const seen = new Set<string>();
220
+ let legacyPutCount = 0;
221
+ for (const row of legacyRows) {
222
+ const nickKey = caseFold('rfc1459', row.account);
223
+ seen.add(nickKey);
224
+ const input = buildServicesPutItem(row, values['services-table']);
225
+ try {
226
+ await docClient.send(
227
+ new PutCommand({
228
+ ...input,
229
+ ConditionExpression: 'attribute_not_exists(pk)',
230
+ }),
231
+ );
232
+ legacyPutCount++;
233
+ } catch (err) {
234
+ const msg = String(err);
235
+ if (msg.includes('ConditionalCheckFailed')) {
236
+ console.log(`Skipped ${row.account} (already registered in services).`);
237
+ } else {
238
+ throw err;
239
+ }
240
+ }
241
+ }
242
+
243
+ const store = await loadDynamoServicesStore(docClient, values['services-table'], SystemClock);
244
+ if (store === undefined) {
245
+ console.error(`Could not load services store from ${values['services-table']}`);
246
+ process.exit(1);
247
+ }
248
+
249
+ let envSeedCount = 0;
250
+ for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
251
+ const nickKey = caseFold('rfc1459', acct.username);
252
+ if (seen.has(nickKey)) continue;
253
+ seen.add(nickKey);
254
+ const result = store.registerNick(acct.username, acct.password, '');
255
+ if (result.ok) {
256
+ envSeedCount++;
257
+ console.log(`Registered ${acct.username} from SASL_ACCOUNTS env seed.`);
258
+ } else {
259
+ console.log(`Skipped ${acct.username} (already registered in services).`);
260
+ }
261
+ }
262
+ await store.flush();
263
+
264
+ console.log(
265
+ `AWS migration complete: ${legacyRows.length} legacy rows scanned ` +
266
+ `(${legacyPutCount} PutItem'd), ${envSeedCount} env-seed accounts registered.`,
267
+ );
268
+ }
269
+
270
+ void main();
@@ -4,6 +4,7 @@
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@serverless-ircd/aws-adapter": "workspace:*",
7
- "@serverless-ircd/cf-adapter": "workspace:*"
7
+ "@serverless-ircd/cf-adapter": "workspace:*",
8
+ "@serverless-ircd/irc-core": "workspace:*"
8
9
  }
9
10
  }
@@ -1,25 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Seed SASL PLAIN accounts into the DynamoDB `Accounts` table.
3
+ * Seed SASL PLAIN accounts into the DynamoDB `Services` table (the unified
4
+ * services credential home).
5
+ *
6
+ * Repointed from the legacy `Accounts` table to the `Services` table —
7
+ * `Services` (`NICK:<fold>` partition) is the single credential home for
8
+ * SASL PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded
9
+ * accounts verify via `services.verifyNick` end-to-end.
4
10
  *
5
11
  * Usage:
6
12
  * node --import tsx tools/seed-aws-accounts.ts \
7
- * --table StagingAccounts \
13
+ * --table StagingServices \
8
14
  * --endpoint http://localhost:8000 \
9
15
  * --accounts alice:s3cret bob:password2
10
16
  *
11
17
  * Or read from a file (newline-delimited `username:password`):
12
18
  * node --import tsx tools/seed-aws-accounts.ts \
13
- * --table StagingAccounts \
19
+ * --table StagingServices \
14
20
  * --file accounts.txt
15
21
  *
16
- * The script hashes each password with scrypt (never writes plaintext).
17
- * Re-running with the same username overwrites the prior row.
22
+ * The script hashes each password with scrypt (never writes plaintext) and
23
+ * writes via a conditional PutItem that never clobbers an existing nick
24
+ * registration (a NickServ-registered nick whose password was set by the
25
+ * user wins over the seed).
18
26
  */
19
27
 
20
28
  import { readFileSync } from 'node:fs';
21
29
  import { parseArgs } from 'node:util';
22
- import { createDynamoDocumentClient, putAccountCredential } from '@serverless-ircd/aws-adapter';
30
+ import { createDynamoDocumentClient, loadDynamoServicesStore } from '@serverless-ircd/aws-adapter';
31
+ import { SystemClock } from '@serverless-ircd/irc-core';
23
32
 
24
33
  const { values } = parseArgs({
25
34
  options: {
@@ -33,7 +42,7 @@ const { values } = parseArgs({
33
42
 
34
43
  if (values.table === undefined) {
35
44
  console.error(
36
- 'Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
45
+ 'Usage: seed-aws-accounts --table <ServicesTableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
37
46
  );
38
47
  process.exit(1);
39
48
  }
@@ -71,9 +80,25 @@ if (pairs.length === 0) {
71
80
  process.exit(1);
72
81
  }
73
82
 
83
+ // Load the services store against the target table, then register each
84
+ // account. `registerNick` is idempotent: it returns `{ ok: false }` when
85
+ // the nick already exists and does NOT overwrite the credential.
86
+ const store = await loadDynamoServicesStore(docClient, values.table, SystemClock);
87
+ if (store === undefined) {
88
+ console.error(`Could not load services store from table ${values.table}.`);
89
+ process.exit(1);
90
+ }
91
+
74
92
  for (const { username, password } of pairs) {
75
- const entry = await putAccountCredential(docClient, values.table, username, password);
76
- console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
93
+ const result = store.registerNick(username, password, '');
94
+ if (result.ok) {
95
+ console.log(`Seeded ${username}`);
96
+ } else {
97
+ console.log(`Skipped ${username} (already registered)`);
98
+ }
77
99
  }
78
100
 
79
- console.log(`Done: ${pairs.length} account(s) written to ${values.table}.`);
101
+ // Flush the write-behind queue so the registrations land in DynamoDB.
102
+ await store.flush();
103
+
104
+ console.log(`Done: ${pairs.length} account(s) processed against ${values.table}.`);
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Seed SASL PLAIN accounts into a Cloudflare D1 `accounts` table.
3
+ * Seed SASL PLAIN accounts into a Cloudflare D1 `nickserv_accounts` table.
4
+ *
5
+ * Repointed from the legacy `accounts` table to the unified services table
6
+ * — `nickserv_accounts` is the single credential home for SASL PLAIN, SASL
7
+ * EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded accounts verify via
8
+ * `services.verifyNick` end-to-end.
4
9
  *
5
10
  * Usage:
6
11
  * node --import tsx tools/seed-cf-accounts.ts \
@@ -17,9 +22,10 @@
17
22
  * --file accounts.txt
18
23
  *
19
24
  * The script hashes each password with scrypt (never writes plaintext) and
20
- * executes the resulting `INSERT OR REPLACE` via `wrangler d1 execute`.
21
- * Re-running with the same username overwrites the prior row. The `accounts`
22
- * table is created (IF NOT EXISTS) on the first run.
25
+ * executes the resulting `INSERT OR IGNORE` via `wrangler d1 execute`.
26
+ * `INSERT OR IGNORE` is idempotent: re-running with the same username does
27
+ * NOT overwrite an existing row (a NickServ-registered nick whose password
28
+ * was set by the user wins over the seed).
23
29
  *
24
30
  * Flags:
25
31
  * --database D1 database name (as declared in wrangler.toml). Required.
@@ -33,10 +39,12 @@
33
39
  import { execFileSync } from 'node:child_process';
34
40
  import { readFileSync } from 'node:fs';
35
41
  import { parseArgs } from 'node:util';
42
+ import { CREATE_NICKSERV_ACCOUNTS_SQL } from '@serverless-ircd/cf-adapter';
36
43
  import {
37
- CREATE_ACCOUNTS_TABLE_SQL,
38
- buildAccountPutStatement,
39
- } from '@serverless-ircd/cf-adapter/accounts';
44
+ type HashedAccountCredential,
45
+ caseFold,
46
+ hashAccountCredential,
47
+ } from '@serverless-ircd/irc-core';
40
48
 
41
49
  const { values } = parseArgs({
42
50
  options: {
@@ -96,10 +104,35 @@ function runD1Execute(sql: string): void {
96
104
  }
97
105
 
98
106
  // Ensure the table exists (idempotent).
99
- runD1Execute(CREATE_ACCOUNTS_TABLE_SQL);
107
+ runD1Execute(CREATE_NICKSERV_ACCOUNTS_SQL);
108
+
109
+ /**
110
+ * Builds a self-contained `INSERT OR IGNORE` SQL statement that writes a
111
+ * scrypt-hashed `nickserv_accounts` row.
112
+ *
113
+ * `INSERT OR IGNORE` (not `INSERT OR REPLACE`) ensures the seed NEVER
114
+ * clobbers an existing registration — a NickServ-registered nick or a
115
+ * prior seed's password wins. The row carries an empty email, the default
116
+ * `'none'` enforcement policy, `cert_subjects = '[]'`, and the scrypt
117
+ * credential derived from the plaintext password.
118
+ */
119
+ export function buildNickServPutStatement(
120
+ username: string,
121
+ password: string,
122
+ ): { entry: HashedAccountCredential; sql: string } {
123
+ const entry = hashAccountCredential(username, password);
124
+ const nickKey = caseFold('rfc1459', username);
125
+ const sql = `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(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
126
+ return { entry, sql };
127
+ }
128
+
129
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
130
+ function sqlEscape(value: string): string {
131
+ return value.replace(/'/g, "''");
132
+ }
100
133
 
101
134
  for (const { username, password } of pairs) {
102
- const { entry, sql } = buildAccountPutStatement(username, password);
135
+ const { entry, sql } = buildNickServPutStatement(username, password);
103
136
  runD1Execute(sql);
104
137
  console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
105
138
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -1,121 +0,0 @@
1
- /**
2
- * SASL account store construction + resolution for the AWS adapter.
3
- *
4
- * Centralises `AccountStore` creation so the actor-construction site
5
- * (`handleDefault`) binds a single helper rather than constructing the
6
- * store inline. Two sources of credentials are reconciled here:
7
- *
8
- * 1. The DynamoDB `Accounts` table (authoritative when populated) —
9
- * loaded at cold start into a {@link DynamoAccountStore} by
10
- * {@link resolveAccountStore}. Credentials are stored as scrypt
11
- * hashes, never plaintext.
12
- * 2. The parsed server config (`saslAccounts`, sourced from the
13
- * `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
14
- * when the table is empty, preserving the config-seed behaviour for
15
- * deployments that have not migrated to the table.
16
- *
17
- * The resolved store is scoped to the Lambda execution context: it
18
- * persists across warm invocations (via the memoised `HandlerDeps`) but
19
- * is rebuilt on cold start, picking up account changes made since the
20
- * last cold start.
21
- */
22
-
23
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
24
- import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
- import {
26
- type AccountStore,
27
- InMemoryAccountStore,
28
- type ParsedServerConfig,
29
- } from '@serverless-ircd/irc-core';
30
- import {
31
- DynamoAccountStore,
32
- type HashedAccountCredential,
33
- hashAccountCredential,
34
- loadDynamoAccountStore,
35
- } from './dynamo-account-store.js';
36
-
37
- // Re-export so callers can import everything from one module.
38
- export {
39
- DynamoAccountStore,
40
- type HashedAccountCredential,
41
- hashAccountCredential,
42
- loadDynamoAccountStore,
43
- };
44
-
45
- /**
46
- * Constructs the config-seeded in-memory account store (the legacy
47
- * fallback). Returns `undefined` when no accounts are configured so the
48
- * actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
49
- * `AUTHENTICATE PLAIN` → `904`).
50
- *
51
- * @param serverConfig Parsed config carrying the `saslAccounts` seed list.
52
- */
53
- export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
54
- if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
55
- return undefined;
56
- }
57
- return new InMemoryAccountStore(serverConfig.saslAccounts);
58
- }
59
-
60
- /**
61
- * Resolves the SASL account store for a deployment, applying the
62
- * table-then-config precedence.
63
- *
64
- * 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
65
- * credential into a {@link DynamoAccountStore}). When the table has
66
- * one or more rows it is authoritative — the table wins and the
67
- * config seed is ignored.
68
- * 2. When the table is empty, falls back to {@link bindAccountStore}
69
- * (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
70
- * have not migrated to the table keep working unchanged.
71
- * 3. Returns `undefined` when neither source has accounts (the default)
72
- * so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
73
- *
74
- * Scan failures (table not found, network error) are swallowed and
75
- * treated as an empty table, falling through to the config seed.
76
- *
77
- * @param docClient DynamoDB client addressed at the deployment's tables.
78
- * @param tableName Physical name of the `Accounts` table for this env.
79
- * @param serverConfig Parsed config (the config-seed fallback source).
80
- */
81
- export async function resolveAccountStore(
82
- docClient: DynamoDBDocumentClient,
83
- tableName: string,
84
- serverConfig?: ParsedServerConfig,
85
- ): Promise<AccountStore | undefined> {
86
- try {
87
- const store = await loadDynamoAccountStore(docClient, tableName);
88
- if (store !== undefined) return store;
89
- } catch {
90
- // Table might not exist or be unreachable; fall through to config seed.
91
- }
92
- return bindAccountStore(serverConfig);
93
- }
94
-
95
- /**
96
- * Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
97
- *
98
- * The password is hashed via {@link hashAccountCredential} (scrypt,
99
- * random salt) and stored as a {@link HashedAccountCredential} row —
100
- * plaintext is never written. A repeat call for the same `username`
101
- * overwrites the prior row (the PK is `account`), so this is the
102
- * canonical CRUD/seed entry point for oper and SASL account
103
- * provisioning.
104
- *
105
- * @returns The stored {@link HashedAccountCredential} (for tooling/tests).
106
- */
107
- export async function putAccountCredential(
108
- docClient: DynamoDBDocumentClient,
109
- tableName: string,
110
- username: string,
111
- password: string,
112
- ): Promise<HashedAccountCredential> {
113
- const entry = hashAccountCredential(username, password);
114
- await docClient.send(
115
- new PutCommand({
116
- TableName: tableName,
117
- Item: entry,
118
- }),
119
- );
120
- return entry;
121
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * DynamoDB-backed {@link AccountStore} primitives for the AWS adapter.
3
- *
4
- * The `Accounts` DynamoDB table stores SASL PLAIN credentials as
5
- * **scrypt hashes** (never plaintext). Each row is a flat
6
- * {@link HashedAccountCredential} (`{ account, algorithm, salt, hash }`).
7
- *
8
- * Two-phase construction keeps the `AccountStore` port synchronous:
9
- * 1. {@link loadDynamoAccountStore} (async) scans the table at Lambda
10
- * cold start and returns a fully-populated `DynamoAccountStore`.
11
- * 2. `DynamoAccountStore(entries)` (sync constructor) takes the
12
- * pre-loaded entries. The class itself has zero DynamoDB
13
- * coupling — it works equally well with entries from any source.
14
- *
15
- * This mirrors the pre-load pattern documented in
16
- * `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
17
- * adapters that need an async backend pre-load the data at boot into a
18
- * synchronously-readable store.
19
- *
20
- * The scrypt hashing helpers (`hashAccountCredential` /
21
- * `verifyHashedPassword` + the `HashedAccountCredential` type) live in
22
- * `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
23
- * are re-exported below so existing call sites importing from this module
24
- * keep working.
25
- */
26
-
27
- import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
28
- import { ScanCommand } from '@aws-sdk/lib-dynamodb';
29
- import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
30
- import {
31
- type HashedAccountCredential,
32
- HashedAccountStore,
33
- hashAccountCredential,
34
- verifyHashedPassword,
35
- } from '@serverless-ircd/irc-core';
36
-
37
- // Re-export so callers importing these from `@serverless-ircd/aws-adapter`
38
- // (the historical home of the hashing helpers) keep compiling.
39
- export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
40
-
41
- /**
42
- * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
43
- *
44
- * Now a re-export of the shared `HashedAccountStore` from `irc-core` —
45
- * both adapters use one store implementation. The AWS-specific surface
46
- * (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
47
- * scan boundary.
48
- */
49
- export { HashedAccountStore as DynamoAccountStore };
50
-
51
- /**
52
- * Scans the `Accounts` table and returns a fully-populated
53
- * {@link DynamoAccountStore}, or `undefined` when the table has no rows.
54
- *
55
- * Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
56
- * scan page are fully loaded. Rows missing required attributes are
57
- * silently skipped.
58
- */
59
- export async function loadDynamoAccountStore(
60
- docClient: DynamoDBDocumentClient,
61
- tableName: string,
62
- ): Promise<HashedAccountStore | undefined> {
63
- const entries: HashedAccountCredential[] = [];
64
- let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
65
- do {
66
- const result: ScanCommandOutput = await docClient.send(
67
- new ScanCommand({
68
- TableName: tableName,
69
- ExclusiveStartKey: exclusiveStartKey,
70
- }),
71
- );
72
- for (const item of result.Items ?? []) {
73
- const account = typeof item.account === 'string' ? item.account : undefined;
74
- const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
75
- const salt = typeof item.salt === 'string' ? item.salt : undefined;
76
- const hash = typeof item.hash === 'string' ? item.hash : undefined;
77
- if (
78
- account !== undefined &&
79
- algorithm !== undefined &&
80
- salt !== undefined &&
81
- hash !== undefined
82
- ) {
83
- entries.push({
84
- account,
85
- algorithm: algorithm as HashedAccountCredential['algorithm'],
86
- salt,
87
- hash,
88
- });
89
- }
90
- }
91
- exclusiveStartKey = result.LastEvaluatedKey;
92
- } while (exclusiveStartKey !== undefined);
93
- if (entries.length === 0) return undefined;
94
- return new HashedAccountStore(entries);
95
- }