@sockethub/data-layer 1.0.0-alpha.11 → 1.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/credentials-store.d.ts +88 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +55596 -29060
- package/dist/index.js.map +157 -11
- package/dist/job-base.d.ts +44 -0
- package/dist/job-queue.d.ts +60 -0
- package/dist/job-worker.d.ts +58 -0
- package/dist/queue-id.d.ts +2 -0
- package/{src/types.ts → dist/types.d.ts} +2 -13
- package/package.json +8 -8
- package/src/credentials-store.test.ts +0 -171
- package/src/credentials-store.ts +0 -192
- package/src/index.ts +0 -34
- package/src/job-base.ts +0 -124
- package/src/job-queue.test.ts +0 -295
- package/src/job-queue.ts +0 -247
- package/src/job-worker.test.ts +0 -142
- package/src/job-worker.ts +0 -116
- package/src/queue-id.test.ts +0 -17
- package/src/queue-id.ts +0 -14
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import type { ActivityStream } from "@sockethub/schemas";
|
|
3
|
+
import { type Crypto } from "@sockethub/util/crypto";
|
|
4
|
+
import { type Redis } from "ioredis";
|
|
5
|
+
import type { JobDataDecrypted, JobEncrypted, RedisConfig } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Creates or returns a shared Redis connection to enable connection pooling.
|
|
8
|
+
* This prevents connection exhaustion under high load by reusing a single
|
|
9
|
+
* connection across all JobQueue and JobWorker instances.
|
|
10
|
+
*
|
|
11
|
+
* @param config - Redis configuration with optional timeout and retry settings
|
|
12
|
+
* @returns Shared Redis connection instance
|
|
13
|
+
*/
|
|
14
|
+
export declare function createIORedisConnection(config: RedisConfig): Redis;
|
|
15
|
+
/**
|
|
16
|
+
* Resets the shared Redis connection. Used primarily for testing.
|
|
17
|
+
* Disconnects the current connection before resetting.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resetSharedRedisConnection(): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Gets the total number of active Redis client connections.
|
|
22
|
+
*
|
|
23
|
+
* Note: This queries the Redis server directly using CLIENT LIST and reports
|
|
24
|
+
* ALL active connections to the Redis instance. This includes connections from
|
|
25
|
+
* Sockethub (BullMQ queues, workers, and the shared connection) as well as any
|
|
26
|
+
* other applications or services connected to the same Redis server.
|
|
27
|
+
*
|
|
28
|
+
* @returns Number of active Redis connections, or 0 if no connection exists
|
|
29
|
+
*/
|
|
30
|
+
export declare function getRedisConnectionCount(): Promise<number>;
|
|
31
|
+
export declare class JobBase extends EventEmitter {
|
|
32
|
+
protected crypto: Crypto;
|
|
33
|
+
private readonly secret;
|
|
34
|
+
constructor(secret: string);
|
|
35
|
+
initCrypto(): void;
|
|
36
|
+
disconnectBase(): void;
|
|
37
|
+
/**
|
|
38
|
+
* @param job
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
protected decryptJobData(job: JobEncrypted): JobDataDecrypted;
|
|
42
|
+
protected decryptActivityStream(msg: string): ActivityStream;
|
|
43
|
+
protected encryptActivityStream(msg: ActivityStream): string;
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ActivityStream } from "@sockethub/schemas";
|
|
2
|
+
import { Queue, QueueEvents } from "bullmq";
|
|
3
|
+
import { JobBase } from "./job-base.js";
|
|
4
|
+
import type { JobDataEncrypted, RedisConfig } from "./types.js";
|
|
5
|
+
export declare function verifyJobQueue(config: RedisConfig): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Redis-backed job queue for managing ActivityStreams message processing.
|
|
8
|
+
*
|
|
9
|
+
* Creates isolated queues per platform instance and session, providing reliable
|
|
10
|
+
* message delivery and processing coordination between Sockethub server and
|
|
11
|
+
* platform workers.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const queue = new JobQueue('irc-platform', 'session123', secret, redisConfig);
|
|
16
|
+
* await queue.add('socket-id', activityStreamMessage);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare class JobQueue extends JobBase {
|
|
20
|
+
private readonly connectionName;
|
|
21
|
+
protected readonly queueId: string;
|
|
22
|
+
protected queue: Queue;
|
|
23
|
+
protected events: QueueEvents;
|
|
24
|
+
private readonly log;
|
|
25
|
+
private counter;
|
|
26
|
+
private initialized;
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new JobQueue instance.
|
|
29
|
+
*
|
|
30
|
+
* @param parentId - Sockethub instance identifier for queue isolation
|
|
31
|
+
* @param instanceId - Unique identifier for the platform instance
|
|
32
|
+
* @param secret - 32-character encryption secret for message security
|
|
33
|
+
* @param redisConfig - Redis connection configuration
|
|
34
|
+
*/
|
|
35
|
+
constructor(parentId: string, instanceId: string, secret: string, redisConfig: RedisConfig);
|
|
36
|
+
protected init(redisConfig: RedisConfig): void;
|
|
37
|
+
/**
|
|
38
|
+
* Adds an ActivityStreams message to the job queue for processing.
|
|
39
|
+
*
|
|
40
|
+
* @param socketId - Socket.IO connection identifier for response routing
|
|
41
|
+
* @param msg - ActivityStreams message to be processed by platform worker
|
|
42
|
+
* @returns Promise resolving to encrypted job data
|
|
43
|
+
* @throws Error if queue is closed or Redis connection fails
|
|
44
|
+
*/
|
|
45
|
+
add(socketId: string, msg: ActivityStream): Promise<JobDataEncrypted>;
|
|
46
|
+
/**
|
|
47
|
+
* Pauses job processing. New jobs can still be added but won't be processed.
|
|
48
|
+
*/
|
|
49
|
+
pause(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Resumes job processing after being paused.
|
|
52
|
+
*/
|
|
53
|
+
resume(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Gracefully shuts down the queue, cleaning up all resources and connections.
|
|
56
|
+
*/
|
|
57
|
+
shutdown(): Promise<void>;
|
|
58
|
+
private getJob;
|
|
59
|
+
private createJob;
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Worker } from "bullmq";
|
|
2
|
+
import { JobBase } from "./job-base.js";
|
|
3
|
+
import type { JobEncrypted, JobHandler, RedisConfig } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Worker for processing jobs from a Redis queue within platform child processes.
|
|
6
|
+
*
|
|
7
|
+
* Connects to the same queue as its corresponding JobQueue instance and processes
|
|
8
|
+
* jobs using a platform-specific handler function. Provides automatic decryption
|
|
9
|
+
* of job data and error handling.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const worker = new JobWorker('irc-platform', 'session123', secret, redisConfig);
|
|
14
|
+
* worker.onJob(async (job) => {
|
|
15
|
+
* // Process the decrypted ActivityStreams message
|
|
16
|
+
* return await processMessage(job.msg);
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export interface JobWorkerOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Number of jobs this worker will process in parallel. Defaults to 1
|
|
23
|
+
* (serial processing). Only safe to raise for stateless platforms whose
|
|
24
|
+
* job handlers are independent of one another (e.g. feeds, metadata).
|
|
25
|
+
*/
|
|
26
|
+
concurrency?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare class JobWorker extends JobBase {
|
|
29
|
+
private readonly connectionName;
|
|
30
|
+
protected worker: Worker;
|
|
31
|
+
protected handler: JobHandler;
|
|
32
|
+
private readonly log;
|
|
33
|
+
private readonly redisConfig;
|
|
34
|
+
protected readonly queueId: string;
|
|
35
|
+
private readonly concurrency;
|
|
36
|
+
private initialized;
|
|
37
|
+
/**
|
|
38
|
+
* Creates a new JobWorker instance.
|
|
39
|
+
*
|
|
40
|
+
* @param parentId - Must match the parentId of the corresponding JobQueue
|
|
41
|
+
* @param instanceId - Must match the instanceId of the corresponding JobQueue
|
|
42
|
+
* @param secret - 32-character encryption secret, must match JobQueue secret
|
|
43
|
+
* @param redisConfig - Redis connection configuration
|
|
44
|
+
*/
|
|
45
|
+
constructor(parentId: string, instanceId: string, secret: string, redisConfig: RedisConfig, options?: JobWorkerOptions);
|
|
46
|
+
protected init(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Registers a job handler function and starts processing jobs from the queue.
|
|
49
|
+
*
|
|
50
|
+
* @param handler - Function that processes decrypted job data and returns results
|
|
51
|
+
*/
|
|
52
|
+
onJob(handler: JobHandler): void;
|
|
53
|
+
/**
|
|
54
|
+
* Gracefully shuts down the worker, stopping job processing and cleaning up connections.
|
|
55
|
+
*/
|
|
56
|
+
shutdown(): Promise<void>;
|
|
57
|
+
protected jobHandler(encryptedJob: JobEncrypted): Promise<any>;
|
|
58
|
+
}
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ActivityStream,
|
|
3
|
-
InternalActivityStream,
|
|
4
|
-
} from "@sockethub/schemas";
|
|
5
|
-
|
|
1
|
+
import type { ActivityStream, InternalActivityStream } from "@sockethub/schemas";
|
|
6
2
|
export type RedisConfig = {
|
|
7
3
|
url: string;
|
|
8
4
|
connectTimeout?: number;
|
|
@@ -10,30 +6,23 @@ export type RedisConfig = {
|
|
|
10
6
|
maxRetriesPerRequest?: number | null;
|
|
11
7
|
connectionName?: string;
|
|
12
8
|
};
|
|
13
|
-
|
|
14
9
|
export interface JobDataEncrypted {
|
|
15
10
|
title?: string;
|
|
16
11
|
msg: string;
|
|
17
12
|
sessionId: string;
|
|
18
13
|
}
|
|
19
|
-
|
|
20
14
|
export interface JobDataDecrypted {
|
|
21
15
|
title?: string;
|
|
22
16
|
msg: InternalActivityStream;
|
|
23
17
|
sessionId: string;
|
|
24
18
|
}
|
|
25
|
-
|
|
26
19
|
export interface JobEncrypted {
|
|
27
20
|
data: JobDataEncrypted;
|
|
28
21
|
remove?: () => void;
|
|
29
22
|
}
|
|
30
|
-
|
|
31
23
|
export interface JobDecrypted {
|
|
32
24
|
data: JobDataDecrypted;
|
|
33
25
|
remove?: () => void;
|
|
34
26
|
returnvalue: unknown;
|
|
35
27
|
}
|
|
36
|
-
|
|
37
|
-
export type JobHandler = (
|
|
38
|
-
job: JobDataDecrypted,
|
|
39
|
-
) => Promise<string | undefined | ActivityStream>;
|
|
28
|
+
export type JobHandler = (job: JobDataDecrypted) => Promise<string | undefined | ActivityStream>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sockethub/data-layer",
|
|
3
3
|
"description": "Storing and RPC of data for Sockethub",
|
|
4
|
-
"version": "1.0.0-alpha.
|
|
4
|
+
"version": "1.0.0-alpha.13",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"author": "Nick Jennings <nick@silverbucket.net>",
|
|
@@ -12,17 +12,16 @@
|
|
|
12
12
|
"main": "dist/index.js",
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
|
-
"
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
16
|
"import": "./dist/index.js",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"src/",
|
|
22
21
|
"dist/"
|
|
23
22
|
],
|
|
24
23
|
"engines": {
|
|
25
|
-
"
|
|
24
|
+
"node": ">=20"
|
|
26
25
|
},
|
|
27
26
|
"keywords": [
|
|
28
27
|
"sockethub",
|
|
@@ -47,9 +46,9 @@
|
|
|
47
46
|
"test:integration": "bun test ./integration/redis.integration.ts"
|
|
48
47
|
},
|
|
49
48
|
"dependencies": {
|
|
50
|
-
"@sockethub/
|
|
51
|
-
"@sockethub/
|
|
52
|
-
"@sockethub/
|
|
49
|
+
"@sockethub/logger": "^1.0.0-alpha.13",
|
|
50
|
+
"@sockethub/schemas": "^3.0.0-alpha.13",
|
|
51
|
+
"@sockethub/util": "^1.0.0-alpha.1",
|
|
53
52
|
"bullmq": "^5.66.5",
|
|
54
53
|
"ioredis": "^5.9.2",
|
|
55
54
|
"secure-store-redis": "^4.1.0"
|
|
@@ -63,5 +62,6 @@
|
|
|
63
62
|
"typedoc-plugin-markdown": "^4.9.0",
|
|
64
63
|
"winston": "^3.19.0"
|
|
65
64
|
},
|
|
66
|
-
"gitHead": "
|
|
65
|
+
"gitHead": "864733e5b34449ef39542eceb4ff11aa308081bc",
|
|
66
|
+
"types": "./dist/index.d.ts"
|
|
67
67
|
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import * as sinon from "sinon";
|
|
3
|
-
|
|
4
|
-
import type { Logger } from "@sockethub/schemas";
|
|
5
|
-
|
|
6
|
-
import { CredentialsStore } from "./credentials-store";
|
|
7
|
-
|
|
8
|
-
const mockLogger: Logger = {
|
|
9
|
-
error: () => {},
|
|
10
|
-
warn: () => {},
|
|
11
|
-
info: () => {},
|
|
12
|
-
debug: () => {},
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe("CredentialsStore", () => {
|
|
16
|
-
let credentialsStore,
|
|
17
|
-
MockSecureStore,
|
|
18
|
-
MockStoreGet,
|
|
19
|
-
MockStoreSave,
|
|
20
|
-
MockObjectHash;
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
MockStoreGet = sinon.stub().returns("credential foo");
|
|
23
|
-
MockStoreSave = sinon.stub();
|
|
24
|
-
MockObjectHash = sinon.stub();
|
|
25
|
-
MockSecureStore = sinon.stub().returns({
|
|
26
|
-
get: MockStoreGet,
|
|
27
|
-
save: MockStoreSave,
|
|
28
|
-
isConnected: true,
|
|
29
|
-
connect: sinon.stub().resolves(),
|
|
30
|
-
});
|
|
31
|
-
class TestCredentialsStore extends CredentialsStore {
|
|
32
|
-
initCrypto() {
|
|
33
|
-
this.objectHash = MockObjectHash;
|
|
34
|
-
}
|
|
35
|
-
initSecureStore(secret, redisConfig) {
|
|
36
|
-
this.store = MockSecureStore({
|
|
37
|
-
namespace: "foo",
|
|
38
|
-
secret: secret,
|
|
39
|
-
redis: redisConfig,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
credentialsStore = new TestCredentialsStore(
|
|
44
|
-
"a parent id",
|
|
45
|
-
"a session id",
|
|
46
|
-
"a secret must be 32 chars and th",
|
|
47
|
-
{ url: "redis config" },
|
|
48
|
-
mockLogger,
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("returns a valid CredentialsStore object", () => {
|
|
53
|
-
sinon.assert.calledOnce(MockSecureStore);
|
|
54
|
-
sinon.assert.calledWith(MockSecureStore, {
|
|
55
|
-
namespace: "foo",
|
|
56
|
-
secret: "a secret must be 32 chars and th",
|
|
57
|
-
redis: {
|
|
58
|
-
url: "redis config",
|
|
59
|
-
connectionName:
|
|
60
|
-
"data-layer:credentials-store:a parent id:a session id",
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
expect(typeof credentialsStore).toEqual("object");
|
|
64
|
-
expect(credentialsStore.uid).toEqual(
|
|
65
|
-
`sockethub:a parent id:data-layer:credentials-store:a session id`,
|
|
66
|
-
);
|
|
67
|
-
expect(typeof credentialsStore.get).toEqual("function");
|
|
68
|
-
expect(typeof credentialsStore.save).toEqual("function");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("get", () => {
|
|
72
|
-
it("handles correct params", async () => {
|
|
73
|
-
const res = await credentialsStore.get("an actor");
|
|
74
|
-
sinon.assert.calledOnce(MockStoreGet);
|
|
75
|
-
sinon.assert.calledWith(MockStoreGet, "an actor");
|
|
76
|
-
sinon.assert.notCalled(MockObjectHash);
|
|
77
|
-
sinon.assert.notCalled(MockStoreSave);
|
|
78
|
-
expect(res).toEqual("credential foo");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("handles no credentials found", async () => {
|
|
82
|
-
MockStoreGet.returns(undefined);
|
|
83
|
-
expect(async () => {
|
|
84
|
-
await credentialsStore.get("a non-existent actor");
|
|
85
|
-
}).toThrow("credentials not found for a non-existent actor");
|
|
86
|
-
sinon.assert.calledOnce(MockStoreGet);
|
|
87
|
-
sinon.assert.calledWith(MockStoreGet, "a non-existent actor");
|
|
88
|
-
sinon.assert.notCalled(MockObjectHash);
|
|
89
|
-
sinon.assert.notCalled(MockStoreSave);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("handles an unexpected error", async () => {
|
|
93
|
-
MockStoreGet.returns(undefined);
|
|
94
|
-
try {
|
|
95
|
-
await credentialsStore.get("a problem actor");
|
|
96
|
-
expect(false).toEqual(true);
|
|
97
|
-
} catch (err) {
|
|
98
|
-
expect(err.toString()).toEqual(
|
|
99
|
-
"Error: credentials not found for a problem actor",
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
sinon.assert.calledOnce(MockStoreGet);
|
|
103
|
-
sinon.assert.calledWith(MockStoreGet, "a problem actor");
|
|
104
|
-
sinon.assert.notCalled(MockObjectHash);
|
|
105
|
-
sinon.assert.notCalled(MockStoreSave);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("validates credentialsHash when provided", async () => {
|
|
109
|
-
MockObjectHash.returns("a credentialsHash string");
|
|
110
|
-
MockStoreGet.returns({
|
|
111
|
-
object: "a credential",
|
|
112
|
-
});
|
|
113
|
-
const res = await credentialsStore.get(
|
|
114
|
-
"an actor",
|
|
115
|
-
"a credentialsHash string",
|
|
116
|
-
);
|
|
117
|
-
sinon.assert.calledOnce(MockStoreGet);
|
|
118
|
-
sinon.assert.calledWith(MockStoreGet, "an actor");
|
|
119
|
-
sinon.assert.calledOnce(MockObjectHash);
|
|
120
|
-
sinon.assert.calledWith(MockObjectHash, "a credential");
|
|
121
|
-
sinon.assert.notCalled(MockStoreSave);
|
|
122
|
-
expect(res).toEqual({ object: "a credential" });
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("invalidates credentialsHash when provided", async () => {
|
|
126
|
-
MockObjectHash.returns("the original credentialsHash string");
|
|
127
|
-
MockStoreGet.returns({
|
|
128
|
-
object: "a credential",
|
|
129
|
-
});
|
|
130
|
-
try {
|
|
131
|
-
expect(
|
|
132
|
-
await credentialsStore.get(
|
|
133
|
-
"an actor",
|
|
134
|
-
"a different credentialsHash string",
|
|
135
|
-
),
|
|
136
|
-
).toBeUndefined();
|
|
137
|
-
expect(false).toEqual(true);
|
|
138
|
-
} catch (err) {
|
|
139
|
-
expect(err.toString()).toEqual(
|
|
140
|
-
"Error: invalid credentials for an actor",
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
sinon.assert.calledOnce(MockStoreGet);
|
|
144
|
-
sinon.assert.calledWith(MockStoreGet, "an actor");
|
|
145
|
-
sinon.assert.calledOnce(MockObjectHash);
|
|
146
|
-
sinon.assert.calledWith(MockObjectHash, "a credential");
|
|
147
|
-
sinon.assert.notCalled(MockStoreSave);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe("save", () => {
|
|
152
|
-
it("handles success", async () => {
|
|
153
|
-
const creds = { foo: "bar" };
|
|
154
|
-
await credentialsStore.save("an actor", creds);
|
|
155
|
-
sinon.assert.calledOnce(MockStoreSave);
|
|
156
|
-
sinon.assert.calledWith(MockStoreSave, "an actor", creds);
|
|
157
|
-
sinon.assert.notCalled(MockObjectHash);
|
|
158
|
-
sinon.assert.notCalled(MockStoreGet);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("handles failure", async () => {
|
|
162
|
-
const creds = { foo: "bar" };
|
|
163
|
-
MockStoreSave.returns(undefined);
|
|
164
|
-
await credentialsStore.save("an actor", creds);
|
|
165
|
-
sinon.assert.calledOnce(MockStoreSave);
|
|
166
|
-
sinon.assert.calledWith(MockStoreSave, "an actor", creds);
|
|
167
|
-
sinon.assert.notCalled(MockObjectHash);
|
|
168
|
-
sinon.assert.notCalled(MockStoreGet);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
});
|
package/src/credentials-store.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { crypto } from "@sockethub/crypto";
|
|
2
|
-
import {
|
|
3
|
-
type Logger,
|
|
4
|
-
createLogger,
|
|
5
|
-
getLoggerNamespace,
|
|
6
|
-
} from "@sockethub/logger";
|
|
7
|
-
import type { CredentialsObject } from "@sockethub/schemas";
|
|
8
|
-
import IORedis, { type Redis } from "ioredis";
|
|
9
|
-
import SecureStore from "secure-store-redis";
|
|
10
|
-
|
|
11
|
-
import { buildCredentialsStoreId } from "./queue-id.js";
|
|
12
|
-
import type { RedisConfig } from "./types.js";
|
|
13
|
-
|
|
14
|
-
let sharedCredentialsRedisConnection: Redis | null = null;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Creates or returns a shared Redis connection for CredentialsStore instances.
|
|
18
|
-
* This prevents connection exhaustion by reusing a single connection across
|
|
19
|
-
* all credential storage operations.
|
|
20
|
-
*
|
|
21
|
-
* @param config - Redis configuration
|
|
22
|
-
* @returns Shared Redis connection instance
|
|
23
|
-
*/
|
|
24
|
-
export function createCredentialsRedisConnection(config: RedisConfig): Redis {
|
|
25
|
-
if (!sharedCredentialsRedisConnection) {
|
|
26
|
-
sharedCredentialsRedisConnection = new IORedis(config.url, {
|
|
27
|
-
connectionName: config.connectionName,
|
|
28
|
-
enableOfflineQueue: false,
|
|
29
|
-
maxRetriesPerRequest: config.maxRetriesPerRequest ?? null,
|
|
30
|
-
connectTimeout: config.connectTimeout ?? 10000,
|
|
31
|
-
disconnectTimeout: config.disconnectTimeout ?? 5000,
|
|
32
|
-
lazyConnect: false,
|
|
33
|
-
retryStrategy: (times: number) => {
|
|
34
|
-
if (times > 3) return null;
|
|
35
|
-
return Math.min(2 ** (times - 1) * 200, 2000);
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
return sharedCredentialsRedisConnection;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Resets the shared credentials Redis connection. Used primarily for testing.
|
|
44
|
-
*/
|
|
45
|
-
export async function resetSharedCredentialsRedisConnection(): Promise<void> {
|
|
46
|
-
if (sharedCredentialsRedisConnection) {
|
|
47
|
-
try {
|
|
48
|
-
sharedCredentialsRedisConnection.disconnect(false);
|
|
49
|
-
} catch (err) {
|
|
50
|
-
// Ignore disconnect errors during cleanup
|
|
51
|
-
}
|
|
52
|
-
sharedCredentialsRedisConnection = null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface CredentialsStoreInterface {
|
|
57
|
-
get(
|
|
58
|
-
actor: string,
|
|
59
|
-
credentialsHash: string | undefined,
|
|
60
|
-
): Promise<CredentialsObject | undefined>;
|
|
61
|
-
save(actor: string, creds: CredentialsObject): Promise<number>;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function verifySecureStore(config: RedisConfig): Promise<void> {
|
|
65
|
-
const log = createLogger("data-layer:verify-secure-store");
|
|
66
|
-
const sharedClient = createCredentialsRedisConnection(config);
|
|
67
|
-
const ss = new SecureStore({
|
|
68
|
-
uid: "data-layer:verify",
|
|
69
|
-
secret: "aB3#xK9mP2qR7wZ4cT8nY6vH1jL5fD0s",
|
|
70
|
-
redis: { client: sharedClient },
|
|
71
|
-
});
|
|
72
|
-
await ss.connect();
|
|
73
|
-
await ss.disconnect();
|
|
74
|
-
log.info("secure store connection verified");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Secure, encrypted storage for user credentials with session-based isolation.
|
|
79
|
-
*
|
|
80
|
-
* Provides automatic encryption/decryption of credential objects stored in Redis,
|
|
81
|
-
* ensuring that sensitive authentication data is never stored in plaintext.
|
|
82
|
-
* Each session gets its own isolated credential store.
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* ```typescript
|
|
86
|
-
* const store = new CredentialsStore('session123', secret, redisConfig);
|
|
87
|
-
*
|
|
88
|
-
* // Store credentials
|
|
89
|
-
* await store.save('user@example.com', {
|
|
90
|
-
* username: 'user',
|
|
91
|
-
* password: 'secret',
|
|
92
|
-
* server: 'irc.freenode.net'
|
|
93
|
-
* });
|
|
94
|
-
*
|
|
95
|
-
* // Retrieve credentials
|
|
96
|
-
* const creds = await store.get('user@example.com', credentialsHash);
|
|
97
|
-
* ```
|
|
98
|
-
*/
|
|
99
|
-
export class CredentialsStore implements CredentialsStoreInterface {
|
|
100
|
-
readonly uid: string;
|
|
101
|
-
store: SecureStore;
|
|
102
|
-
objectHash: (o: unknown) => string;
|
|
103
|
-
private readonly log: Logger;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Creates a new CredentialsStore instance.
|
|
107
|
-
*
|
|
108
|
-
* @param parentId - Unique identifier for the parent instance (e.g. server ID)
|
|
109
|
-
* @param sessionId - Client session identifier for credential isolation
|
|
110
|
-
* @param secret - 32-character encryption secret for credential security
|
|
111
|
-
* @param redisConfig - Redis connection configuration
|
|
112
|
-
* @throws Error if secret is not exactly 32 characters
|
|
113
|
-
*/
|
|
114
|
-
constructor(
|
|
115
|
-
parentId: string,
|
|
116
|
-
sessionId: string,
|
|
117
|
-
secret: string,
|
|
118
|
-
redisConfig: RedisConfig,
|
|
119
|
-
) {
|
|
120
|
-
if (secret.length !== 32) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
"CredentialsStore secret must be 32 chars in length",
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
// Create logger with full namespace (context will be prepended automatically)
|
|
126
|
-
this.log = createLogger(
|
|
127
|
-
`data-layer:credentials-store:${parentId}:${sessionId}`,
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
this.initCrypto();
|
|
131
|
-
|
|
132
|
-
// Use the canonical, context-free namespace for credentials storage keys
|
|
133
|
-
this.uid = buildCredentialsStoreId(parentId, sessionId);
|
|
134
|
-
// Keep full logger namespace for Redis connection naming
|
|
135
|
-
redisConfig.connectionName = getLoggerNamespace(this.log);
|
|
136
|
-
this.initSecureStore(secret, redisConfig);
|
|
137
|
-
this.log.debug("initialized");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
initCrypto() {
|
|
141
|
-
this.objectHash = crypto.objectHash;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
initSecureStore(secret: string, redisConfig: RedisConfig) {
|
|
145
|
-
// Use shared Redis connection for connection pooling
|
|
146
|
-
const sharedClient = createCredentialsRedisConnection(redisConfig);
|
|
147
|
-
this.store = new SecureStore({
|
|
148
|
-
uid: this.uid,
|
|
149
|
-
secret: secret,
|
|
150
|
-
redis: { client: sharedClient },
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Gets the credentials for a given actor ID.
|
|
156
|
-
* @param actor
|
|
157
|
-
* @param credentialsHash - Optional hash to validate credentials.
|
|
158
|
-
* If undefined, validation is skipped.
|
|
159
|
-
*/
|
|
160
|
-
async get(
|
|
161
|
-
actor: string,
|
|
162
|
-
credentialsHash: string | undefined,
|
|
163
|
-
): Promise<CredentialsObject> {
|
|
164
|
-
this.log.debug(`get credentials for ${actor}`);
|
|
165
|
-
if (!this.store.isConnected) {
|
|
166
|
-
await this.store.connect();
|
|
167
|
-
}
|
|
168
|
-
const credentials: CredentialsObject = await this.store.get(actor);
|
|
169
|
-
if (!credentials) {
|
|
170
|
-
throw new Error(`credentials not found for ${actor}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (credentialsHash) {
|
|
174
|
-
if (credentialsHash !== this.objectHash(credentials.object)) {
|
|
175
|
-
throw new Error(`invalid credentials for ${actor}`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return credentials;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Saves the credentials for a given actor ID
|
|
183
|
-
* @param actor
|
|
184
|
-
* @param creds
|
|
185
|
-
*/
|
|
186
|
-
async save(actor: string, creds: CredentialsObject): Promise<number> {
|
|
187
|
-
if (!this.store.isConnected) {
|
|
188
|
-
await this.store.connect();
|
|
189
|
-
}
|
|
190
|
-
return this.store.save(actor, creds);
|
|
191
|
-
}
|
|
192
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { createLogger } from "@sockethub/logger";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
CredentialsStore,
|
|
5
|
-
type CredentialsStoreInterface,
|
|
6
|
-
resetSharedCredentialsRedisConnection,
|
|
7
|
-
verifySecureStore,
|
|
8
|
-
} from "./credentials-store.js";
|
|
9
|
-
import {
|
|
10
|
-
getRedisConnectionCount,
|
|
11
|
-
resetSharedRedisConnection,
|
|
12
|
-
} from "./job-base.js";
|
|
13
|
-
import { JobQueue, verifyJobQueue } from "./job-queue.js";
|
|
14
|
-
import { JobWorker } from "./job-worker.js";
|
|
15
|
-
export * from "./types.js";
|
|
16
|
-
import type { RedisConfig } from "./types.js";
|
|
17
|
-
|
|
18
|
-
async function redisCheck(config: RedisConfig): Promise<void> {
|
|
19
|
-
const log = createLogger("data-layer:redis-check");
|
|
20
|
-
log.debug(`checking redis connection ${config.url}`);
|
|
21
|
-
await verifySecureStore(config);
|
|
22
|
-
await verifyJobQueue(config);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export {
|
|
26
|
-
redisCheck,
|
|
27
|
-
JobQueue,
|
|
28
|
-
JobWorker,
|
|
29
|
-
CredentialsStore,
|
|
30
|
-
getRedisConnectionCount,
|
|
31
|
-
resetSharedRedisConnection,
|
|
32
|
-
resetSharedCredentialsRedisConnection,
|
|
33
|
-
type CredentialsStoreInterface,
|
|
34
|
-
};
|