serverless-ircd 0.8.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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DynamoDB-backed integration tests for the `Accounts` table path.
|
|
3
|
-
* Exercises the real DynamoDB round-trip that pure unit
|
|
4
|
-
* tests cannot: `putAccountCredential` writes a hashed row,
|
|
5
|
-
* `loadDynamoAccountStore` scans it back, and `resolveAccountStore`
|
|
6
|
-
* applies the table-then-config precedence.
|
|
7
|
-
*
|
|
8
|
-
* Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
|
|
9
|
-
* `handlers.test.ts`).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
-
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
-
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
15
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
-
import {
|
|
17
|
-
loadDynamoAccountStore,
|
|
18
|
-
putAccountCredential,
|
|
19
|
-
resolveAccountStore,
|
|
20
|
-
} from '../src/account-store.js';
|
|
21
|
-
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
22
|
-
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
23
|
-
|
|
24
|
-
const available = process.env.DDB_AVAILABLE === '1';
|
|
25
|
-
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
26
|
-
|
|
27
|
-
interface Fx {
|
|
28
|
-
tableName: string;
|
|
29
|
-
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
30
|
-
cleanup: () => Promise<void>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
let fx: Fx | null = null;
|
|
34
|
-
|
|
35
|
-
function requireFx(): Fx {
|
|
36
|
-
if (!fx) throw new Error('test fixture not initialized');
|
|
37
|
-
return fx;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function makeFx(): Promise<Fx> {
|
|
41
|
-
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
42
|
-
const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
43
|
-
const client = createDynamoDocumentClient({ endpoint });
|
|
44
|
-
const props = TABLE_DEFS.Accounts;
|
|
45
|
-
const pkName = props.partitionKey?.name;
|
|
46
|
-
if (pkName === undefined) throw new Error('Accounts table missing partitionKey');
|
|
47
|
-
await client.send(
|
|
48
|
-
new CreateTableCommand({
|
|
49
|
-
TableName: tableName,
|
|
50
|
-
AttributeDefinitions: [{ AttributeName: pkName, AttributeType: 'S' }],
|
|
51
|
-
KeySchema: [{ AttributeName: pkName, KeyType: 'HASH' }],
|
|
52
|
-
BillingMode: 'PAY_PER_REQUEST',
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
return {
|
|
56
|
-
tableName,
|
|
57
|
-
client,
|
|
58
|
-
cleanup: async () => {
|
|
59
|
-
try {
|
|
60
|
-
await client.send(new DeleteTableCommand({ TableName: tableName }));
|
|
61
|
-
} catch {
|
|
62
|
-
// Idempotent.
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
describe.skipIf(!available)('Accounts table round-trip', () => {
|
|
69
|
-
beforeEach(async () => {
|
|
70
|
-
fx = await makeFx();
|
|
71
|
-
});
|
|
72
|
-
afterEach(async () => {
|
|
73
|
-
if (fx) await fx.cleanup();
|
|
74
|
-
fx = null;
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
|
|
78
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
|
|
79
|
-
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
80
|
-
expect(store).toBeDefined();
|
|
81
|
-
expect(
|
|
82
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
|
|
83
|
-
).toEqual({
|
|
84
|
-
ok: true,
|
|
85
|
-
account: 'alice',
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
|
|
90
|
-
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
91
|
-
expect(store).toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('putAccountCredential writes multiple distinct accounts', async () => {
|
|
95
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
|
|
96
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
|
|
97
|
-
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
98
|
-
expect(store).toBeDefined();
|
|
99
|
-
expect(store?.size).toBe(2);
|
|
100
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
|
|
101
|
-
true,
|
|
102
|
-
);
|
|
103
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
|
|
104
|
-
true,
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('putAccountCredential overwrites an existing account (re-seed)', async () => {
|
|
109
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
|
|
110
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
|
|
111
|
-
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
112
|
-
expect(store).toBeDefined();
|
|
113
|
-
expect(store?.size).toBe(1);
|
|
114
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
|
|
115
|
-
false,
|
|
116
|
-
);
|
|
117
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
|
|
118
|
-
true,
|
|
119
|
-
);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('rejects a wrong password after round-trip', async () => {
|
|
123
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
|
|
124
|
-
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
125
|
-
expect(store).toBeDefined();
|
|
126
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
127
|
-
false,
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('silently skips rows with missing or wrong-typed credential fields', async () => {
|
|
132
|
-
// The scan path must tolerate partially-malformed rows (legacy seeds,
|
|
133
|
-
// schema drift) without throwing — it just skips them. Each branch of
|
|
134
|
-
// the per-field `typeof === 'string'` guard is exercised by writing a
|
|
135
|
-
// row where exactly one non-key field has the wrong type. (The
|
|
136
|
-
// `account` field is the partition key, so DynamoDB itself enforces
|
|
137
|
-
// its type at write time and the false branch is not exercisable
|
|
138
|
-
// through the SDK; that branch is a defensive guard against
|
|
139
|
-
// synthetically-shaped Scan results.)
|
|
140
|
-
const client = requireFx().client;
|
|
141
|
-
const table = requireFx().tableName;
|
|
142
|
-
// Well-formed row — must survive.
|
|
143
|
-
await putAccountCredential(client, table, 'good', 's3cret');
|
|
144
|
-
// Row with non-string `algorithm`.
|
|
145
|
-
await client.send(
|
|
146
|
-
new PutCommand({
|
|
147
|
-
TableName: table,
|
|
148
|
-
Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
|
|
149
|
-
}),
|
|
150
|
-
);
|
|
151
|
-
// Row with non-string `salt`.
|
|
152
|
-
await client.send(
|
|
153
|
-
new PutCommand({
|
|
154
|
-
TableName: table,
|
|
155
|
-
Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
// Row with non-string `hash`.
|
|
159
|
-
await client.send(
|
|
160
|
-
new PutCommand({
|
|
161
|
-
TableName: table,
|
|
162
|
-
Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
|
|
163
|
-
}),
|
|
164
|
-
);
|
|
165
|
-
const store = await loadDynamoAccountStore(client, table);
|
|
166
|
-
expect(store).toBeDefined();
|
|
167
|
-
expect(store?.size).toBe(1);
|
|
168
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
|
|
169
|
-
true,
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
175
|
-
beforeEach(async () => {
|
|
176
|
-
fx = await makeFx();
|
|
177
|
-
});
|
|
178
|
-
afterEach(async () => {
|
|
179
|
-
if (fx) await fx.cleanup();
|
|
180
|
-
fx = null;
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
184
|
-
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
|
|
185
|
-
const cfgWithSasl = makeTestServerConfig({
|
|
186
|
-
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
187
|
-
});
|
|
188
|
-
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
189
|
-
expect(store).toBeDefined();
|
|
190
|
-
// Table account verifies; config account does NOT (table is authoritative).
|
|
191
|
-
expect(
|
|
192
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
|
|
193
|
-
).toBe(true);
|
|
194
|
-
expect(
|
|
195
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
196
|
-
).toBe(false);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('falls back to the config-seeded store when the table is empty', async () => {
|
|
200
|
-
const cfgWithSasl = makeTestServerConfig({
|
|
201
|
-
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
202
|
-
});
|
|
203
|
-
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
204
|
-
expect(store).toBeDefined();
|
|
205
|
-
expect(
|
|
206
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
207
|
-
).toBe(true);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('returns undefined when the table is empty and no config accounts are set', async () => {
|
|
211
|
-
const store = await resolveAccountStore(
|
|
212
|
-
requireFx().client,
|
|
213
|
-
requireFx().tableName,
|
|
214
|
-
makeTestServerConfig(),
|
|
215
|
-
);
|
|
216
|
-
expect(store).toBeUndefined();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('returns undefined when the table is empty and serverConfig is undefined', async () => {
|
|
220
|
-
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
|
|
221
|
-
expect(store).toBeUndefined();
|
|
222
|
-
});
|
|
223
|
-
});
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
2
|
-
import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
|
|
3
|
-
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
4
|
-
import { mockClient } from 'aws-sdk-client-mock';
|
|
5
|
-
import { describe, expect, it } from 'vitest';
|
|
6
|
-
import {
|
|
7
|
-
DynamoAccountStore,
|
|
8
|
-
type HashedAccountCredential,
|
|
9
|
-
bindAccountStore,
|
|
10
|
-
hashAccountCredential,
|
|
11
|
-
putAccountCredential,
|
|
12
|
-
resolveAccountStore,
|
|
13
|
-
} from '../src/account-store';
|
|
14
|
-
|
|
15
|
-
function plainPayload(username: string, password: string): SaslPayload {
|
|
16
|
-
return { kind: 'PLAIN', username, password };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Builds a minimal parsed config with the supplied saslAccounts. */
|
|
20
|
-
function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
|
|
21
|
-
return makeTestServerConfig({ saslAccounts: accounts });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
// bindAccountStore — config-seeded fallback
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
describe('bindAccountStore', () => {
|
|
29
|
-
it('returns an AccountStore that accepts valid PLAIN creds', () => {
|
|
30
|
-
const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
|
|
31
|
-
expect(store).toBeDefined();
|
|
32
|
-
const out = store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
|
|
33
|
-
expect(out).toEqual({ ok: true, account: 'alice' });
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('returns undefined when the account list is empty', () => {
|
|
37
|
-
expect(bindAccountStore(configWith([]))).toBeUndefined();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('returns undefined when serverConfig is undefined', () => {
|
|
41
|
-
expect(bindAccountStore(undefined)).toBeUndefined();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('rejects wrong credentials', () => {
|
|
45
|
-
const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
|
|
46
|
-
expect(store).toBeDefined();
|
|
47
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
48
|
-
false,
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// hashAccountCredential — pure scrypt hashing helper
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
describe('hashAccountCredential', () => {
|
|
58
|
-
it('produces an entry keyed by the username with scrypt', () => {
|
|
59
|
-
const entry = hashAccountCredential('alice', 'secret');
|
|
60
|
-
expect(entry.account).toBe('alice');
|
|
61
|
-
expect(entry.algorithm).toBe('scrypt');
|
|
62
|
-
expect(entry.salt.length).toBeGreaterThan(0);
|
|
63
|
-
expect(entry.hash.length).toBeGreaterThan(0);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('never stores the plaintext password in any field', () => {
|
|
67
|
-
const entry = hashAccountCredential('alice', 'hunter2-secret');
|
|
68
|
-
expect(entry.account).not.toContain('hunter2');
|
|
69
|
-
expect(entry.salt).not.toContain('hunter2');
|
|
70
|
-
expect(entry.hash).not.toContain('hunter2');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('produces different hashes for the same password under different salts', () => {
|
|
74
|
-
const a = hashAccountCredential('alice', 'secret', { salt: Buffer.from('aaaaaaaaaaaaaaaa') });
|
|
75
|
-
const b = hashAccountCredential('alice', 'secret', { salt: Buffer.from('bbbbbbbbbbbbbbbb') });
|
|
76
|
-
expect(a.hash).not.toBe(b.hash);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('is deterministic for a fixed salt', () => {
|
|
80
|
-
const salt = Buffer.from('1234567890123456');
|
|
81
|
-
const a = hashAccountCredential('alice', 'secret', { salt });
|
|
82
|
-
const b = hashAccountCredential('alice', 'secret', { salt });
|
|
83
|
-
expect(a.hash).toBe(b.hash);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('honours a custom key length', () => {
|
|
87
|
-
const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
|
|
88
|
-
// base64 of 32 bytes → ~44 chars; base64 of 64 bytes → ~88 chars
|
|
89
|
-
expect(Buffer.from(entry.hash, 'base64').length).toBe(32);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// DynamoAccountStore — synchronous verify against pre-loaded hashed entries
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
describe('DynamoAccountStore', () => {
|
|
98
|
-
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
99
|
-
const entry = hashAccountCredential('alice', 'secret');
|
|
100
|
-
const store = new DynamoAccountStore([entry]);
|
|
101
|
-
expect(store.verify('PLAIN', plainPayload('alice', 'secret'))).toEqual({
|
|
102
|
-
ok: true,
|
|
103
|
-
account: 'alice',
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('rejects a wrong password', () => {
|
|
108
|
-
const entry = hashAccountCredential('alice', 'secret');
|
|
109
|
-
const store = new DynamoAccountStore([entry]);
|
|
110
|
-
expect(store.verify('PLAIN', plainPayload('alice', 'wrong')).ok).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('rejects an unknown username', () => {
|
|
114
|
-
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
115
|
-
expect(store.verify('PLAIN', plainPayload('bob', 'secret')).ok).toBe(false);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('rejects a non-PLAIN mechanism', () => {
|
|
119
|
-
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
120
|
-
expect(store.verify('EXTERNAL', plainPayload('alice', 'secret')).ok).toBe(false);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('rejects a RAW payload', () => {
|
|
124
|
-
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
125
|
-
expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('handles multiple loaded accounts', () => {
|
|
129
|
-
const store = new DynamoAccountStore([
|
|
130
|
-
hashAccountCredential('alice', 'a-secret'),
|
|
131
|
-
hashAccountCredential('bob', 'b-secret'),
|
|
132
|
-
]);
|
|
133
|
-
expect(store.verify('PLAIN', plainPayload('alice', 'a-secret'))).toEqual({
|
|
134
|
-
ok: true,
|
|
135
|
-
account: 'alice',
|
|
136
|
-
});
|
|
137
|
-
expect(store.verify('PLAIN', plainPayload('bob', 'b-secret'))).toEqual({
|
|
138
|
-
ok: true,
|
|
139
|
-
account: 'bob',
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('fails every verify when constructed with no entries', () => {
|
|
144
|
-
const store = new DynamoAccountStore([]);
|
|
145
|
-
expect(store.verify('PLAIN', plainPayload('alice', 'secret')).ok).toBe(false);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('round-trips an externally-constructed HashedAccountCredential', () => {
|
|
149
|
-
const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
|
|
150
|
-
const store = new DynamoAccountStore([row]);
|
|
151
|
-
expect(store.verify('PLAIN', plainPayload('carol', 'p@ss'))).toEqual({
|
|
152
|
-
ok: true,
|
|
153
|
-
account: 'carol',
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('reports the number of loaded entries via size', () => {
|
|
158
|
-
const store = new DynamoAccountStore([
|
|
159
|
-
hashAccountCredential('alice', 'a'),
|
|
160
|
-
hashAccountCredential('bob', 'b'),
|
|
161
|
-
]);
|
|
162
|
-
expect(store.size).toBe(2);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// resolveAccountStore — table-then-config precedence (mock-based)
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
describe('resolveAccountStore', () => {
|
|
171
|
-
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
172
|
-
const entry = hashAccountCredential('alice', 's3cret');
|
|
173
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
174
|
-
ddb.on(ScanCommand).resolves({
|
|
175
|
-
Items: [entry],
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const fallback = bindAccountStore(configWith([{ username: 'envuser', password: 'env-pw' }]));
|
|
179
|
-
const store = await resolveAccountStore(
|
|
180
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
181
|
-
'Accounts',
|
|
182
|
-
fallback === undefined
|
|
183
|
-
? undefined
|
|
184
|
-
: makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
|
|
185
|
-
);
|
|
186
|
-
expect(store).toBeDefined();
|
|
187
|
-
expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
|
|
188
|
-
// Config account does NOT verify (table is authoritative).
|
|
189
|
-
expect(store?.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok).toBe(false);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('falls back to config-seeded store when the table is empty', async () => {
|
|
193
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
194
|
-
ddb.on(ScanCommand).resolves({ Items: [] });
|
|
195
|
-
|
|
196
|
-
const store = await resolveAccountStore(
|
|
197
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
198
|
-
'Accounts',
|
|
199
|
-
configWith([{ username: 'bob', password: 'pw' }]),
|
|
200
|
-
);
|
|
201
|
-
expect(store).toBeDefined();
|
|
202
|
-
expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('falls back to config-seeded store when the scan throws', async () => {
|
|
206
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
207
|
-
ddb.on(ScanCommand).rejects(new Error('ResourceNotFoundException'));
|
|
208
|
-
|
|
209
|
-
const store = await resolveAccountStore(
|
|
210
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
211
|
-
'Accounts',
|
|
212
|
-
configWith([{ username: 'bob', password: 'pw' }]),
|
|
213
|
-
);
|
|
214
|
-
expect(store).toBeDefined();
|
|
215
|
-
expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('returns undefined when table is empty and no config accounts', async () => {
|
|
219
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
220
|
-
ddb.on(ScanCommand).resolves({ Items: [] });
|
|
221
|
-
|
|
222
|
-
const store = await resolveAccountStore(
|
|
223
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
224
|
-
'Accounts',
|
|
225
|
-
undefined,
|
|
226
|
-
);
|
|
227
|
-
expect(store).toBeUndefined();
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
// putAccountCredential — CRUD/seed entry point (mock-based)
|
|
233
|
-
// ---------------------------------------------------------------------------
|
|
234
|
-
|
|
235
|
-
describe('putAccountCredential', () => {
|
|
236
|
-
it('writes a hashed row to DynamoDB and returns the credential', async () => {
|
|
237
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
238
|
-
ddb.on(PutCommand).resolves({});
|
|
239
|
-
|
|
240
|
-
const entry = await putAccountCredential(
|
|
241
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
242
|
-
'Accounts',
|
|
243
|
-
'alice',
|
|
244
|
-
's3cret',
|
|
245
|
-
);
|
|
246
|
-
expect(entry.account).toBe('alice');
|
|
247
|
-
expect(entry.algorithm).toBe('scrypt');
|
|
248
|
-
|
|
249
|
-
const calls = ddb.commandCalls(PutCommand);
|
|
250
|
-
expect(calls).toHaveLength(1);
|
|
251
|
-
const item = calls[0]?.args[0].input.Item as HashedAccountCredential;
|
|
252
|
-
expect(item.account).toBe('alice');
|
|
253
|
-
expect(item.algorithm).toBe('scrypt');
|
|
254
|
-
expect(item.salt).toBe(entry.salt);
|
|
255
|
-
expect(item.hash).toBe(entry.hash);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('the written row round-trips through DynamoAccountStore', async () => {
|
|
259
|
-
const ddb = mockClient(DynamoDBDocumentClient);
|
|
260
|
-
ddb.on(PutCommand).resolves({});
|
|
261
|
-
|
|
262
|
-
const entry = await putAccountCredential(
|
|
263
|
-
ddb as unknown as DynamoDBDocumentClient,
|
|
264
|
-
'Accounts',
|
|
265
|
-
'alice',
|
|
266
|
-
's3cret',
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
// Simulate reading the row back via DynamoAccountStore.
|
|
270
|
-
const store = new DynamoAccountStore([entry]);
|
|
271
|
-
expect(store.verify('PLAIN', plainPayload('alice', 's3cret'))).toEqual({
|
|
272
|
-
ok: true,
|
|
273
|
-
account: 'alice',
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
});
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* D1-backed SASL account store primitives for the Cloudflare adapter.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the AWS `dynamo-account-store.ts` / `account-store.ts` pair:
|
|
5
|
-
* credentials are stored as scrypt hashes (never plaintext) in a D1
|
|
6
|
-
* `accounts` table and pre-loaded at {@link ConnectionDO} construction
|
|
7
|
-
* into the shared {@link HashedAccountStore}.
|
|
8
|
-
*
|
|
9
|
-
* Table-then-config precedence is implemented by {@link resolveAccountStore}:
|
|
10
|
-
* when the D1 table has rows it is authoritative; when it is empty (or
|
|
11
|
-
* unreachable, or the binding is absent) the code falls back to the
|
|
12
|
-
* `SASL_ACCOUNTS` env-var seed — preserving the config-seed behaviour for
|
|
13
|
-
* deployments that have not migrated to D1.
|
|
14
|
-
*
|
|
15
|
-
* The adapters diverge only at the scan/put boundary; the hashing helpers
|
|
16
|
-
* and the synchronous {@link HashedAccountStore} are shared via `irc-core`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
type AccountStore,
|
|
21
|
-
type HashedAccountCredential,
|
|
22
|
-
HashedAccountStore,
|
|
23
|
-
InMemoryAccountStore,
|
|
24
|
-
hashAccountCredential,
|
|
25
|
-
} from '@serverless-ircd/irc-core';
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* SQL to create the `accounts` table. Exported so the seed tool and
|
|
29
|
-
* provisioning docs reference one schema definition.
|
|
30
|
-
*/
|
|
31
|
-
export const CREATE_ACCOUNTS_TABLE_SQL =
|
|
32
|
-
'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A raw D1 row — the untyped shape returned by `prepare().all()`. Each
|
|
36
|
-
* column is `unknown` until validated into a {@link HashedAccountCredential}.
|
|
37
|
-
*/
|
|
38
|
-
type AccountsRow = Record<string, unknown>;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Coerces a raw D1 row into a {@link HashedAccountCredential}, or returns
|
|
42
|
-
* `undefined` when the row is missing required columns or has the wrong
|
|
43
|
-
* types. Mirrors the silent-skip behaviour of the DynamoDB loader.
|
|
44
|
-
*/
|
|
45
|
-
function coerceRow(row: AccountsRow): HashedAccountCredential | undefined {
|
|
46
|
-
const { account, algorithm, salt, hash } = row;
|
|
47
|
-
if (
|
|
48
|
-
typeof account !== 'string' ||
|
|
49
|
-
typeof algorithm !== 'string' ||
|
|
50
|
-
typeof salt !== 'string' ||
|
|
51
|
-
typeof hash !== 'string'
|
|
52
|
-
) {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
account,
|
|
57
|
-
algorithm: algorithm as HashedAccountCredential['algorithm'],
|
|
58
|
-
salt,
|
|
59
|
-
hash,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Queries the D1 `accounts` table and returns a fully-populated
|
|
65
|
-
* {@link HashedAccountStore}, or `undefined` when the table has no rows.
|
|
66
|
-
*
|
|
67
|
-
* Rows missing required attributes are silently skipped. Callers that need
|
|
68
|
-
* the table-then-config fallback should use {@link resolveAccountStore}
|
|
69
|
-
* rather than catching `undefined` themselves.
|
|
70
|
-
*
|
|
71
|
-
* @param db The D1 binding (may be `undefined` when D1 is not configured).
|
|
72
|
-
*/
|
|
73
|
-
export async function loadD1AccountStore(
|
|
74
|
-
db: D1Database | undefined,
|
|
75
|
-
): Promise<HashedAccountStore | undefined> {
|
|
76
|
-
if (db === undefined) return undefined;
|
|
77
|
-
const result = await db
|
|
78
|
-
.prepare('SELECT account, algorithm, salt, hash FROM accounts')
|
|
79
|
-
.all<AccountsRow>();
|
|
80
|
-
const entries: HashedAccountCredential[] = [];
|
|
81
|
-
for (const row of result.results ?? []) {
|
|
82
|
-
const entry = coerceRow(row);
|
|
83
|
-
if (entry !== undefined) entries.push(entry);
|
|
84
|
-
}
|
|
85
|
-
if (entries.length === 0) return undefined;
|
|
86
|
-
return new HashedAccountStore(entries);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Writes (or re-seeds) a SASL PLAIN account into the D1 `accounts` table.
|
|
91
|
-
*
|
|
92
|
-
* The password is hashed via {@link hashAccountCredential} (scrypt, random
|
|
93
|
-
* salt) and stored as a {@link HashedAccountCredential} row — plaintext is
|
|
94
|
-
* never written. A repeat call for the same `username` overwrites the prior
|
|
95
|
-
* row (the PK is `account`), so this is the canonical CRUD/seed entry point
|
|
96
|
-
* for oper and SASL account provisioning.
|
|
97
|
-
*
|
|
98
|
-
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
99
|
-
*/
|
|
100
|
-
export async function putAccountCredential(
|
|
101
|
-
db: D1Database,
|
|
102
|
-
username: string,
|
|
103
|
-
password: string,
|
|
104
|
-
): Promise<HashedAccountCredential> {
|
|
105
|
-
const entry = hashAccountCredential(username, password);
|
|
106
|
-
await db
|
|
107
|
-
.prepare('INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)')
|
|
108
|
-
.bind(entry.account, entry.algorithm, entry.salt, entry.hash)
|
|
109
|
-
.run();
|
|
110
|
-
return entry;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Builds a self-contained `INSERT OR REPLACE` SQL statement that writes a
|
|
115
|
-
* scrypt-hashed account row, suitable for `wrangler d1 execute --command`.
|
|
116
|
-
*
|
|
117
|
-
* The password is hashed via {@link hashAccountCredential}; the plaintext
|
|
118
|
-
* never appears in the SQL. The values are single-quoted with embedded
|
|
119
|
-
* single quotes doubled (`'` → `''`) to avoid injection through the
|
|
120
|
-
* username or the base64 output.
|
|
121
|
-
*
|
|
122
|
-
* This is the pure (non-IO) half of {@link putAccountCredential}; the seed
|
|
123
|
-
* CLI (`tools/seed-cf-accounts.ts`) calls this and pipes the result to
|
|
124
|
-
* `wrangler d1 execute`, while the runtime `ConnectionDO` path uses the
|
|
125
|
-
* bound-parameter {@link putAccountCredential}.
|
|
126
|
-
*
|
|
127
|
-
* @returns The hashed credential and the executable SQL string.
|
|
128
|
-
*/
|
|
129
|
-
export function buildAccountPutStatement(
|
|
130
|
-
username: string,
|
|
131
|
-
password: string,
|
|
132
|
-
): { entry: HashedAccountCredential; sql: string } {
|
|
133
|
-
const entry = hashAccountCredential(username, password);
|
|
134
|
-
const sql = `INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES ('${sqlEscape(entry.account)}', '${sqlEscape(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
|
|
135
|
-
return { entry, sql };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
139
|
-
function sqlEscape(value: string): string {
|
|
140
|
-
return value.replace(/'/g, "''");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Resolves the SASL account store for a CF deployment, applying the
|
|
145
|
-
* table-then-config precedence.
|
|
146
|
-
*
|
|
147
|
-
* 1. Queries the D1 `accounts` table (pre-loading every hashed credential
|
|
148
|
-
* into a {@link HashedAccountStore}). When the table has one or more
|
|
149
|
-
* rows it is authoritative — the table wins and the config seed is
|
|
150
|
-
* ignored.
|
|
151
|
-
* 2. When the table is empty (or the binding is absent), falls back to
|
|
152
|
-
* the `SASL_ACCOUNTS` env-var seed (an {@link InMemoryAccountStore})
|
|
153
|
-
* so existing deployments that have not migrated to D1 keep working.
|
|
154
|
-
* 3. Returns `undefined` when neither source has accounts so the actor's
|
|
155
|
-
* `ctx.accounts` stays unset (`AUTHENTICATE PLAIN` → `904`).
|
|
156
|
-
*
|
|
157
|
-
* Query failures are swallowed and treated as an empty table, falling
|
|
158
|
-
* through to the config seed — mirroring `resolveAccountStore` on AWS.
|
|
159
|
-
*
|
|
160
|
-
* @param db The D1 binding (`undefined` when D1 is not configured).
|
|
161
|
-
* @param saslAccountsRaw The raw `SASL_ACCOUNTS` env var (newline-delimited
|
|
162
|
-
* `username:password` pairs), or `undefined` when unset.
|
|
163
|
-
*/
|
|
164
|
-
export async function resolveAccountStore(
|
|
165
|
-
db: D1Database | undefined,
|
|
166
|
-
saslAccountsRaw: string | undefined,
|
|
167
|
-
): Promise<AccountStore | undefined> {
|
|
168
|
-
try {
|
|
169
|
-
const store = await loadD1AccountStore(db);
|
|
170
|
-
if (store !== undefined) return store;
|
|
171
|
-
} catch {
|
|
172
|
-
// Table might not exist yet or be unreachable; fall through to seed.
|
|
173
|
-
}
|
|
174
|
-
const seed = parseSeed(saslAccountsRaw);
|
|
175
|
-
if (seed.length === 0) return undefined;
|
|
176
|
-
return new InMemoryAccountStore(seed);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Parses the `SASL_ACCOUNTS` env var into credential entries for the
|
|
181
|
-
* config-seed fallback. Re-exported from the in-memory account-store module
|
|
182
|
-
* so the precedence resolver is self-contained.
|
|
183
|
-
*/
|
|
184
|
-
function parseSeed(raw: string | undefined): Array<{ username: string; password: string }> {
|
|
185
|
-
if (raw === undefined || raw.length === 0) return [];
|
|
186
|
-
const out: Array<{ username: string; password: string }> = [];
|
|
187
|
-
for (const line of raw.split('\n')) {
|
|
188
|
-
const trimmed = line.trim();
|
|
189
|
-
if (trimmed.length === 0) continue;
|
|
190
|
-
const sep = trimmed.indexOf(':');
|
|
191
|
-
if (sep <= 0) continue;
|
|
192
|
-
const username = trimmed.slice(0, sep);
|
|
193
|
-
const password = trimmed.slice(sep + 1);
|
|
194
|
-
if (username.length === 0 || password.length === 0) continue;
|
|
195
|
-
out.push({ username, password });
|
|
196
|
-
}
|
|
197
|
-
return out;
|
|
198
|
-
}
|