@sockethub/data-layer 1.0.0-alpha.10

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/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@sockethub/data-layer",
3
+ "description": "Storing and RPC of data for Sockethub",
4
+ "version": "1.0.0-alpha.10",
5
+ "type": "module",
6
+ "private": false,
7
+ "author": "Nick Jennings <nick@silverbucket.net>",
8
+ "license": "MIT",
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "main": "dist/index.js",
13
+ "exports": {
14
+ ".": {
15
+ "bun": "./src/index.ts",
16
+ "import": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "src/",
22
+ "dist/"
23
+ ],
24
+ "engines": {
25
+ "bun": ">=1.2"
26
+ },
27
+ "keywords": [
28
+ "sockethub",
29
+ "messaging",
30
+ "redis",
31
+ "data layer",
32
+ "rpc",
33
+ "data store"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/sockethub/sockethub.git",
38
+ "directory": "packages/data-layer"
39
+ },
40
+ "homepage": "https://github.com/sockethub/sockethub/tree/master/packages/data-layer",
41
+ "scripts": {
42
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --sourcemap=external",
43
+ "clean": "rm -rf dist",
44
+ "doc": "typedoc --options typedoc.json",
45
+ "test": "bun test src/",
46
+ "test:integration": "DEBUG=ioredis*,bullmq*,sockethub* bun test ./integration/redis.integration.ts"
47
+ },
48
+ "dependencies": {
49
+ "@sockethub/crypto": "^1.0.0-alpha.10",
50
+ "@sockethub/schemas": "^3.0.0-alpha.10",
51
+ "bullmq": "^5.66.5",
52
+ "debug": "^4.4.3",
53
+ "ioredis": "^5.9.2",
54
+ "secure-store-redis": "^3.0.7"
55
+ },
56
+ "devDependencies": {
57
+ "@types/bun": "latest",
58
+ "@types/debug": "^4.1.12",
59
+ "@types/sinon": "^17.0.4",
60
+ "sinon": "^17.0.2",
61
+ "typedoc": "^0.28.16",
62
+ "typedoc-plugin-markdown": "^4.9.0"
63
+ },
64
+ "gitHead": "8e1abf116b2a6b57d33c6e1a4af9143870517bae"
65
+ }
@@ -0,0 +1,155 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import * as sinon from "sinon";
3
+
4
+ import { CredentialsStore } from "./credentials-store";
5
+
6
+ describe("CredentialsStore", () => {
7
+ let credentialsStore,
8
+ MockSecureStore,
9
+ MockStoreGet,
10
+ MockStoreSave,
11
+ MockObjectHash;
12
+ beforeEach(() => {
13
+ MockStoreGet = sinon.stub().returns("credential foo");
14
+ MockStoreSave = sinon.stub();
15
+ MockObjectHash = sinon.stub();
16
+ MockSecureStore = sinon.stub().returns({
17
+ get: MockStoreGet,
18
+ save: MockStoreSave,
19
+ });
20
+ class TestCredentialsStore extends CredentialsStore {
21
+ initCrypto() {
22
+ this.objectHash = MockObjectHash;
23
+ }
24
+ initSecureStore(secret, redisConfig) {
25
+ this.store = MockSecureStore({
26
+ namespace: "foo",
27
+ secret: secret,
28
+ redis: redisConfig,
29
+ });
30
+ }
31
+ }
32
+ credentialsStore = new TestCredentialsStore(
33
+ "a parent id",
34
+ "a session id",
35
+ "a secret must be 32 chars and th",
36
+ { url: "redis config" },
37
+ );
38
+ });
39
+
40
+ it("returns a valid CredentialsStore object", () => {
41
+ sinon.assert.calledOnce(MockSecureStore);
42
+ sinon.assert.calledWith(MockSecureStore, {
43
+ namespace: "foo",
44
+ secret: "a secret must be 32 chars and th",
45
+ redis: { url: "redis config" },
46
+ });
47
+ expect(typeof credentialsStore).toEqual("object");
48
+ expect(credentialsStore.uid).toEqual(
49
+ `sockethub:data-layer:credentials-store:a parent id:a session id`,
50
+ );
51
+ expect(typeof credentialsStore.get).toEqual("function");
52
+ expect(typeof credentialsStore.save).toEqual("function");
53
+ });
54
+
55
+ describe("get", () => {
56
+ it("handles correct params", async () => {
57
+ const res = await credentialsStore.get("an actor");
58
+ sinon.assert.calledOnce(MockStoreGet);
59
+ sinon.assert.calledWith(MockStoreGet, "an actor");
60
+ sinon.assert.notCalled(MockObjectHash);
61
+ sinon.assert.notCalled(MockStoreSave);
62
+ expect(res).toEqual("credential foo");
63
+ });
64
+
65
+ it("handles no credentials found", async () => {
66
+ MockStoreGet.returns(undefined);
67
+ expect(async () => {
68
+ await credentialsStore.get("a non-existent actor");
69
+ }).toThrow("credentials not found for a non-existent actor");
70
+ sinon.assert.calledOnce(MockStoreGet);
71
+ sinon.assert.calledWith(MockStoreGet, "a non-existent actor");
72
+ sinon.assert.notCalled(MockObjectHash);
73
+ sinon.assert.notCalled(MockStoreSave);
74
+ });
75
+
76
+ it("handles an unexpected error", async () => {
77
+ MockStoreGet.returns(undefined);
78
+ try {
79
+ await credentialsStore.get("a problem actor");
80
+ expect(false).toEqual(true);
81
+ } catch (err) {
82
+ expect(err.toString()).toEqual(
83
+ "Error: credentials not found for a problem actor",
84
+ );
85
+ }
86
+ sinon.assert.calledOnce(MockStoreGet);
87
+ sinon.assert.calledWith(MockStoreGet, "a problem actor");
88
+ sinon.assert.notCalled(MockObjectHash);
89
+ sinon.assert.notCalled(MockStoreSave);
90
+ });
91
+
92
+ it("validates credentialsHash when provided", async () => {
93
+ MockObjectHash.returns("a credentialsHash string");
94
+ MockStoreGet.returns({
95
+ object: "a credential",
96
+ });
97
+ const res = await credentialsStore.get(
98
+ "an actor",
99
+ "a credentialsHash string",
100
+ );
101
+ sinon.assert.calledOnce(MockStoreGet);
102
+ sinon.assert.calledWith(MockStoreGet, "an actor");
103
+ sinon.assert.calledOnce(MockObjectHash);
104
+ sinon.assert.calledWith(MockObjectHash, "a credential");
105
+ sinon.assert.notCalled(MockStoreSave);
106
+ expect(res).toEqual({ object: "a credential" });
107
+ });
108
+
109
+ it("invalidates credentialsHash when provided", async () => {
110
+ MockObjectHash.returns("the original credentialsHash string");
111
+ MockStoreGet.returns({
112
+ object: "a credential",
113
+ });
114
+ try {
115
+ expect(
116
+ await credentialsStore.get(
117
+ "an actor",
118
+ "a different credentialsHash string",
119
+ ),
120
+ ).toBeUndefined();
121
+ expect(false).toEqual(true);
122
+ } catch (err) {
123
+ expect(err.toString()).toEqual(
124
+ "Error: invalid credentials for an actor",
125
+ );
126
+ }
127
+ sinon.assert.calledOnce(MockStoreGet);
128
+ sinon.assert.calledWith(MockStoreGet, "an actor");
129
+ sinon.assert.calledOnce(MockObjectHash);
130
+ sinon.assert.calledWith(MockObjectHash, "a credential");
131
+ sinon.assert.notCalled(MockStoreSave);
132
+ });
133
+ });
134
+
135
+ describe("save", () => {
136
+ it("handles success", async () => {
137
+ const creds = { foo: "bar" };
138
+ await credentialsStore.save("an actor", creds);
139
+ sinon.assert.calledOnce(MockStoreSave);
140
+ sinon.assert.calledWith(MockStoreSave, "an actor", creds);
141
+ sinon.assert.notCalled(MockObjectHash);
142
+ sinon.assert.notCalled(MockStoreGet);
143
+ });
144
+
145
+ it("handles failure", async () => {
146
+ const creds = { foo: "bar" };
147
+ MockStoreSave.returns(undefined);
148
+ await credentialsStore.save("an actor", creds);
149
+ sinon.assert.calledOnce(MockStoreSave);
150
+ sinon.assert.calledWith(MockStoreSave, "an actor", creds);
151
+ sinon.assert.notCalled(MockObjectHash);
152
+ sinon.assert.notCalled(MockStoreGet);
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,124 @@
1
+ import { crypto } from "@sockethub/crypto";
2
+ import type { CredentialsObject } from "@sockethub/schemas";
3
+ import debug, { type Debugger } from "debug";
4
+ import SecureStore from "secure-store-redis";
5
+
6
+ import type { RedisConfig } from "./types.js";
7
+
8
+ export interface CredentialsStoreInterface {
9
+ get(
10
+ actor: string,
11
+ credentialsHash: string | undefined,
12
+ ): Promise<CredentialsObject | undefined>;
13
+ save(actor: string, creds: CredentialsObject): Promise<number>;
14
+ }
15
+
16
+ export async function verifySecureStore(config: RedisConfig): Promise<void> {
17
+ const log = debug("sockethub:data-layer:credentials-store");
18
+ const ss = new SecureStore({
19
+ redis: config,
20
+ });
21
+ await ss.init();
22
+ await ss.disconnect();
23
+ log("redis connection verified");
24
+ }
25
+
26
+ /**
27
+ * Secure, encrypted storage for user credentials with session-based isolation.
28
+ *
29
+ * Provides automatic encryption/decryption of credential objects stored in Redis,
30
+ * ensuring that sensitive authentication data is never stored in plaintext.
31
+ * Each session gets its own isolated credential store.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const store = new CredentialsStore('session123', secret, redisConfig);
36
+ *
37
+ * // Store credentials
38
+ * await store.save('user@example.com', {
39
+ * username: 'user',
40
+ * password: 'secret',
41
+ * server: 'irc.freenode.net'
42
+ * });
43
+ *
44
+ * // Retrieve credentials
45
+ * const creds = await store.get('user@example.com', credentialsHash);
46
+ * ```
47
+ */
48
+ export class CredentialsStore implements CredentialsStoreInterface {
49
+ readonly uid: string;
50
+ store: SecureStore;
51
+ objectHash: (o: unknown) => string;
52
+ private readonly log: Debugger;
53
+
54
+ /**
55
+ * Creates a new CredentialsStore instance.
56
+ *
57
+ * @param parentId - Unique identifier for the parent instance (e.g. server ID)
58
+ * @param sessionId - Client session identifier for credential isolation
59
+ * @param secret - 32-character encryption secret for credential security
60
+ * @param redisConfig - Redis connection configuration
61
+ * @throws Error if secret is not exactly 32 characters
62
+ */
63
+ constructor(
64
+ parentId: string,
65
+ sessionId: string,
66
+ secret: string,
67
+ redisConfig: RedisConfig,
68
+ ) {
69
+ if (secret.length !== 32) {
70
+ throw new Error(
71
+ "CredentialsStore secret must be 32 chars in length",
72
+ );
73
+ }
74
+ this.uid = `sockethub:data-layer:credentials-store:${parentId}:${sessionId}`;
75
+ this.log = debug(this.uid);
76
+ this.initCrypto();
77
+ this.initSecureStore(secret, redisConfig);
78
+ this.log("initialized");
79
+ }
80
+
81
+ initCrypto() {
82
+ this.objectHash = crypto.objectHash;
83
+ }
84
+
85
+ initSecureStore(secret: string, redisConfig: RedisConfig) {
86
+ this.store = new SecureStore({
87
+ uid: this.uid,
88
+ secret: secret,
89
+ redis: redisConfig,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Gets the credentials for a given actor ID
95
+ * @param actor
96
+ * @param credentialsHash - Optional hash to validate credentials. If undefined, validation is skipped.
97
+ */
98
+ async get(
99
+ actor: string,
100
+ credentialsHash: string | undefined,
101
+ ): Promise<CredentialsObject> {
102
+ this.log(`get credentials for ${actor}`);
103
+ const credentials: CredentialsObject = await this.store.get(actor);
104
+ if (!credentials) {
105
+ throw new Error(`credentials not found for ${actor}`);
106
+ }
107
+
108
+ if (credentialsHash) {
109
+ if (credentialsHash !== this.objectHash(credentials.object)) {
110
+ throw new Error(`invalid credentials for ${actor}`);
111
+ }
112
+ }
113
+ return credentials;
114
+ }
115
+
116
+ /**
117
+ * Saves the credentials for a given actor ID
118
+ * @param actor
119
+ * @param creds
120
+ */
121
+ async save(actor: string, creds: CredentialsObject): Promise<number> {
122
+ return this.store.save(actor, creds);
123
+ }
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ import debug from "debug";
2
+
3
+ import {
4
+ CredentialsStore,
5
+ type CredentialsStoreInterface,
6
+ verifySecureStore,
7
+ } from "./credentials-store.js";
8
+ import { JobQueue, verifyJobQueue } from "./job-queue.js";
9
+ import { JobWorker } from "./job-worker.js";
10
+ export * from "./types.js";
11
+ import type { RedisConfig } from "./types.js";
12
+
13
+ const log = debug("sockethub:data-layer");
14
+
15
+ async function redisCheck(config: RedisConfig): Promise<void> {
16
+ log(`checking redis connection ${config.url}`);
17
+ await verifySecureStore(config);
18
+ await verifyJobQueue(config);
19
+ }
20
+
21
+ export {
22
+ redisCheck,
23
+ JobQueue,
24
+ JobWorker,
25
+ CredentialsStore,
26
+ type CredentialsStoreInterface,
27
+ };
@@ -0,0 +1,58 @@
1
+ import EventEmitter from "node:events";
2
+ import IORedis, { type Redis } from "ioredis";
3
+
4
+ import { crypto, type Crypto } from "@sockethub/crypto";
5
+ import type { ActivityStream } from "@sockethub/schemas";
6
+
7
+ import type { JobDataDecrypted, JobEncrypted, RedisConfig } from "./types.js";
8
+
9
+ export function createIORedisConnection(config: RedisConfig): Redis {
10
+ return new IORedis(config.url, {
11
+ enableOfflineQueue: false,
12
+ maxRetriesPerRequest: null,
13
+ });
14
+ }
15
+
16
+ export class JobBase extends EventEmitter {
17
+ protected crypto: Crypto;
18
+ private readonly secret: string;
19
+
20
+ constructor(secret: string) {
21
+ super();
22
+ if (secret.length !== 32) {
23
+ throw new Error(
24
+ `secret must be a 32 char string, length: ${secret.length}`,
25
+ );
26
+ }
27
+ this.secret = secret;
28
+ this.initCrypto();
29
+ }
30
+
31
+ initCrypto() {
32
+ this.crypto = crypto;
33
+ }
34
+
35
+ disconnectBase() {
36
+ this.removeAllListeners();
37
+ }
38
+
39
+ /**
40
+ * @param job
41
+ * @private
42
+ */
43
+ protected decryptJobData(job: JobEncrypted): JobDataDecrypted {
44
+ return {
45
+ title: job.data.title,
46
+ msg: this.decryptActivityStream(job.data.msg) as ActivityStream,
47
+ sessionId: job.data.sessionId,
48
+ };
49
+ }
50
+
51
+ protected decryptActivityStream(msg: string): ActivityStream {
52
+ return this.crypto.decrypt(msg, this.secret);
53
+ }
54
+
55
+ protected encryptActivityStream(msg: ActivityStream): string {
56
+ return this.crypto.encrypt(msg, this.secret);
57
+ }
58
+ }