@estopia/shared 1.0.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/tests.yml +29 -0
- package/package.json +37 -0
- package/scripts/migrations/1670000000000-initial.js +107 -0
- package/scripts/package.json +20 -0
- package/src/broker/eventTypes.ts +23 -0
- package/src/broker/publisher.ts +82 -0
- package/src/broker/subscriber.ts +51 -0
- package/src/database/cockroach.ts +27 -0
- package/src/database/dataTypes.ts +117 -0
- package/src/database/types.ts +9 -0
- package/src/helpers/middlware/auth.ts +55 -0
- package/src/helpers/middlware/secure.ts +48 -0
- package/src/helpers/validateJWTToken.ts +38 -0
- package/src/index.ts +24 -0
- package/src/logging/logger.ts +103 -0
- package/src/types/ENUMS.ts +45 -0
- package/src/types/directs.ts +18 -0
- package/src/types/medical.ts +36 -0
- package/src/types/messages.ts +27 -0
- package/src/types/request.ts +23 -0
- package/src/types/servers.ts +36 -0
- package/src/types/servicePayloads.ts +40 -0
- package/src/types/ticketsAndReporting.ts +32 -0
- package/src/types/user.ts +34 -0
- package/tests/verifyJWTToken.test.ts +27 -0
- package/tsconfig.json +48 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v3
|
|
15
|
+
|
|
16
|
+
- name: Use Node.js
|
|
17
|
+
uses: actions/setup-node@v3
|
|
18
|
+
with:
|
|
19
|
+
node-version: '18'
|
|
20
|
+
|
|
21
|
+
- name: Install package dependencies
|
|
22
|
+
run: |
|
|
23
|
+
npm install
|
|
24
|
+
shell: bash
|
|
25
|
+
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: |
|
|
28
|
+
npm test
|
|
29
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estopia/shared",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.d.ts",
|
|
6
|
+
"description": "",
|
|
7
|
+
"author": "",
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"test": "vitest",
|
|
12
|
+
"test:watch": "vitest --watch",
|
|
13
|
+
"db:types": "kysely-codegen --out-file ./src/database/dataTypes.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"amqplib": "^0.10.9",
|
|
17
|
+
"axios": "^1.12.2",
|
|
18
|
+
"cassandra-driver": "^4.8.0",
|
|
19
|
+
"express": "^5.1.0",
|
|
20
|
+
"jsonwebtoken": "^9.0.2",
|
|
21
|
+
"kysely": "^0.28.8",
|
|
22
|
+
"pg": "^8.16.3",
|
|
23
|
+
"pino": "^10.0.0",
|
|
24
|
+
"vitest": "^3.2.4"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/amqplib": "^0.10.7",
|
|
28
|
+
"@types/express": "^5.0.3",
|
|
29
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
30
|
+
"@types/node": "^24.7.1",
|
|
31
|
+
"@types/pg": "^8.15.5",
|
|
32
|
+
"kysely-codegen": "^0.19.0",
|
|
33
|
+
"pino-pretty": "^13.1.2",
|
|
34
|
+
"tsx": "^4.20.6",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
exports.up = (pgm) => {
|
|
5
|
+
// 1. Create Custom Enum Types
|
|
6
|
+
pgm.createType('verification_type', ['email', 'phone', 'password_reset']);
|
|
7
|
+
pgm.createType('factor_auth_type', ['whatsapp', 'email']);
|
|
8
|
+
pgm.createType('app_type', ['medIOS', 'medAndroid', 'web']);
|
|
9
|
+
|
|
10
|
+
// 2. Create Core User Table
|
|
11
|
+
pgm.createTable('users', {
|
|
12
|
+
id: 'id',
|
|
13
|
+
username: { type: 'varchar(50)', notNull: true, unique: true },
|
|
14
|
+
password_hash: { type: 'text', notNull: true },
|
|
15
|
+
disabled: { type: 'boolean', default: false },
|
|
16
|
+
is_admin: { type: 'boolean', default: false },
|
|
17
|
+
icon_url: { type: 'text' },
|
|
18
|
+
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 3. Create Related Authentication & Settings Tables
|
|
22
|
+
pgm.createTable('notification_settings', {
|
|
23
|
+
id: 'id',
|
|
24
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE', unique: true },
|
|
25
|
+
direct_message: { type: 'boolean', default: true },
|
|
26
|
+
group_dm_message: { type: 'boolean', default: true },
|
|
27
|
+
group_dm_mention: { type: 'boolean', default: true },
|
|
28
|
+
added_to_dm: { type: 'boolean', default: true },
|
|
29
|
+
server_everyone_mention: { type: 'boolean', default: true },
|
|
30
|
+
server_mention: { type: 'boolean', default: true },
|
|
31
|
+
server_reply: { type: 'boolean', default: true },
|
|
32
|
+
direct_call: { type: 'boolean', default: true },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
pgm.createTable('user_verifications', {
|
|
36
|
+
id: 'id',
|
|
37
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
38
|
+
ownership_code: { type: 'text', notNull: true },
|
|
39
|
+
is_verified: { type: 'boolean', default: false },
|
|
40
|
+
type: { type: 'verification_type', notNull: true },
|
|
41
|
+
data: { type: 'text' },
|
|
42
|
+
expiry_at: { type: 'timestamptz', notNull: true },
|
|
43
|
+
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pgm.createTable('passkeys', {
|
|
47
|
+
id: 'id',
|
|
48
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
49
|
+
key_id: { type: 'bytea', notNull: true, unique: true },
|
|
50
|
+
public_key: { type: 'bytea', notNull: true },
|
|
51
|
+
counter: { type: 'bigint', notNull: true },
|
|
52
|
+
transports: { type: 'varchar(50)[]' },
|
|
53
|
+
name: { type: 'text' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
pgm.createTable('auth_apps', {
|
|
57
|
+
id: 'id',
|
|
58
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
59
|
+
secret: { type: 'text', notNull: true },
|
|
60
|
+
name: { type: 'varchar(100)', notNull: true },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pgm.createTable('factor_authentication', {
|
|
64
|
+
id: 'id',
|
|
65
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
66
|
+
type: { type: 'factor_auth_type', notNull: true },
|
|
67
|
+
data: { type: 'text', notNull: true },
|
|
68
|
+
code: { type: 'text', notNull: true },
|
|
69
|
+
is_verified: { type: 'boolean', default: false },
|
|
70
|
+
expiry_at: { type: 'timestamptz', notNull: true },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pgm.createTable('tokens', {
|
|
74
|
+
id: 'id',
|
|
75
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
76
|
+
refresh_token: { type: 'uuid', notNull: true, unique: true },
|
|
77
|
+
fcm_token: { type: 'text' },
|
|
78
|
+
app: { type: 'app_type', notNull: false },
|
|
79
|
+
expiry_at: { type: 'timestamptz', notNull: true },
|
|
80
|
+
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pgm.createTable('secure_tokens', {
|
|
84
|
+
id: 'id',
|
|
85
|
+
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
|
86
|
+
token_uuid: { type: 'uuid', notNull: true, unique: true },
|
|
87
|
+
expiry_at: { type: 'timestamptz', notNull: true },
|
|
88
|
+
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
exports.down = (pgm) => {
|
|
93
|
+
// Drop tables in reverse order of creation due to dependencies
|
|
94
|
+
pgm.dropTable('secure_tokens');
|
|
95
|
+
pgm.dropTable('tokens');
|
|
96
|
+
pgm.dropTable('factor_authentication');
|
|
97
|
+
pgm.dropTable('auth_apps');
|
|
98
|
+
pgm.dropTable('passkeys');
|
|
99
|
+
pgm.dropTable('user_verifications');
|
|
100
|
+
pgm.dropTable('notification_settings');
|
|
101
|
+
pgm.dropTable('users');
|
|
102
|
+
|
|
103
|
+
// Drop types
|
|
104
|
+
pgm.dropType('app_type');
|
|
105
|
+
pgm.dropType('factor_auth_type');
|
|
106
|
+
pgm.dropType('verification_type');
|
|
107
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estopia/scripts",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.ts",
|
|
5
|
+
"types": "src/index.d.ts",
|
|
6
|
+
"description": "",
|
|
7
|
+
"author": "",
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"migrate:up": "dotenv -e .env -- node-pg-migrate up -u \"$DATABASE_URL\"",
|
|
11
|
+
"migrate:down": "dotenv -e .env -- node-pg-migrate down -u \"$DATABASE_URL\""
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"dotenv": "^17.2.3",
|
|
15
|
+
"node-pg-migrate": "^8.0.3"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"dotenv-cli": "^10.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface UserRegisteredEvent {
|
|
2
|
+
userId: string;
|
|
3
|
+
email: string;
|
|
4
|
+
verificationCode: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PasswordResetEvent {
|
|
8
|
+
userId: string;
|
|
9
|
+
email: string;
|
|
10
|
+
resetToken: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type EventPayloads = UserRegisteredEvent | PasswordResetEvent;
|
|
14
|
+
|
|
15
|
+
export interface EventMap {
|
|
16
|
+
USER_REGISTERED: UserRegisteredEvent;
|
|
17
|
+
PASSWORD_RESET: PasswordResetEvent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const EVENTS = {
|
|
21
|
+
USER_REGISTERED: 'user.registered',
|
|
22
|
+
PASSWORD_RESET: 'password.reset',
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as amqplib from 'amqplib';
|
|
2
|
+
import { EventPayloads, EventMap, EVENTS } from './eventTypes';
|
|
3
|
+
|
|
4
|
+
export class EventPublisher {
|
|
5
|
+
private connection?: any;
|
|
6
|
+
private channel?: any;
|
|
7
|
+
private exchange = 'estopia.events';
|
|
8
|
+
|
|
9
|
+
constructor(private url: string) {}
|
|
10
|
+
|
|
11
|
+
async connect() {
|
|
12
|
+
if (this.connection && this.channel) return; // already connected
|
|
13
|
+
this.connection = await amqplib.connect(this.url);
|
|
14
|
+
this.channel = await this.connection.createChannel();
|
|
15
|
+
await this.channel.assertExchange(this.exchange, 'topic', { durable: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Publish an event. The payload type is keyed by the same keys used in your EventPayloads map.
|
|
20
|
+
* Ensures the routing key exists in EVENTS and that the publisher is connected.
|
|
21
|
+
*/
|
|
22
|
+
async publish<K extends keyof EventMap>(eventName: K, payload: EventMap[K]) {
|
|
23
|
+
// Auto-connect if needed
|
|
24
|
+
if (!this.channel) {
|
|
25
|
+
await this.connect();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const routingKey = EVENTS[eventName as keyof typeof EVENTS];
|
|
29
|
+
if (!routingKey || typeof routingKey !== 'string') {
|
|
30
|
+
throw new Error(`No routing key configured for event ${String(eventName)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let body: Buffer;
|
|
34
|
+
try {
|
|
35
|
+
body = Buffer.from(JSON.stringify(payload));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error('Failed to serialize payload for publish');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Basic retry with exponential backoff
|
|
41
|
+
const maxRetries = 3;
|
|
42
|
+
const baseBackoffMs = 200;
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
// channel.publish is synchronous; wrap in try/catch in case of closed channel
|
|
47
|
+
this.channel.publish(this.exchange, routingKey, body, { persistent: true });
|
|
48
|
+
return;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// if last attempt, rethrow
|
|
51
|
+
if (attempt === maxRetries) throw err;
|
|
52
|
+
// try reconnect + backoff
|
|
53
|
+
try {
|
|
54
|
+
// reset connection and attempt reconnect
|
|
55
|
+
await this.close();
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
try {
|
|
58
|
+
await this.connect();
|
|
59
|
+
} catch (_) {
|
|
60
|
+
// failed to reconnect, will backoff and retry loop
|
|
61
|
+
}
|
|
62
|
+
const backoff = baseBackoffMs * Math.pow(2, attempt);
|
|
63
|
+
await new Promise((res) => setTimeout(res, backoff));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async close() {
|
|
69
|
+
try {
|
|
70
|
+
if (this.channel) await this.channel.close();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
if (this.connection) await this.connection.close();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// ignore
|
|
78
|
+
}
|
|
79
|
+
this.channel = undefined;
|
|
80
|
+
this.connection = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const amqplib = require('amqplib');
|
|
2
|
+
import { EVENTS, EventMap } from './eventTypes';
|
|
3
|
+
|
|
4
|
+
type EventHandler<T> = (payload: T) => Promise<void> | void;
|
|
5
|
+
|
|
6
|
+
export class EventSubscriber {
|
|
7
|
+
private connection?: any;
|
|
8
|
+
private channel?: any;
|
|
9
|
+
private exchange = 'estopia.events';
|
|
10
|
+
|
|
11
|
+
constructor(private url: string) {}
|
|
12
|
+
|
|
13
|
+
async connect() {
|
|
14
|
+
if (this.connection && this.channel) return;
|
|
15
|
+
this.connection = await amqplib.connect(this.url);
|
|
16
|
+
this.channel = await this.connection.createChannel();
|
|
17
|
+
await this.channel.assertExchange(this.exchange, 'topic', { durable: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async subscribe<K extends keyof EventMap>(eventName: K, handler: EventHandler<EventMap[K]>) {
|
|
21
|
+
if (!this.channel) throw new Error('Subscriber not connected');
|
|
22
|
+
|
|
23
|
+
const q = await this.channel.assertQueue('', { exclusive: true });
|
|
24
|
+
const routing = EVENTS[eventName as keyof typeof EVENTS];
|
|
25
|
+
await this.channel.bindQueue(q.queue, this.exchange, routing);
|
|
26
|
+
|
|
27
|
+
await this.channel.consume(q.queue, (msg: any | null) => {
|
|
28
|
+
if (msg) {
|
|
29
|
+
let payload: EventMap[K];
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(msg.content.toString());
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// malformed message -> nack and requeue false
|
|
34
|
+
this.channel?.nack(msg, false, false);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Promise.resolve(handler(payload))
|
|
39
|
+
.then(() => this.channel?.ack(msg))
|
|
40
|
+
.catch(() => this.channel?.nack(msg, false, true));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async close() {
|
|
46
|
+
try { if (this.channel) await this.channel.close(); } catch (e) { }
|
|
47
|
+
try { if (this.connection) await this.connection.close(); } catch (e) { }
|
|
48
|
+
this.channel = undefined;
|
|
49
|
+
this.connection = undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { CockroachConfig } from './types';
|
|
3
|
+
import { Kysely, PostgresDialect } from 'kysely';
|
|
4
|
+
import { DB } from './dataTypes';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates and tests a new connection pool for a CockroachDB cluster.
|
|
8
|
+
* * @param config - The connection configuration for CockroachDB.
|
|
9
|
+
* @returns A promise that resolves to a connected Pool instance.
|
|
10
|
+
* @throws Will throw an error if the connection fails.
|
|
11
|
+
*/
|
|
12
|
+
export async function createCockroachDBConnection(config: CockroachConfig): Promise<Kysely<DB>> {
|
|
13
|
+
try {
|
|
14
|
+
|
|
15
|
+
const db = new Kysely<DB>({
|
|
16
|
+
dialect: new PostgresDialect({
|
|
17
|
+
pool: new Pool(config)
|
|
18
|
+
})
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
console.log('Successfully connected to CockroachDB.');
|
|
22
|
+
return db;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('Failed to connect to CockroachDB:', error);
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was generated by kysely-codegen.
|
|
3
|
+
* Please do not edit it manually.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ColumnType } from "kysely";
|
|
7
|
+
|
|
8
|
+
export type AppType = "medAndroid" | "medIOS" | "web";
|
|
9
|
+
|
|
10
|
+
export type FactorAuthType = "email" | "whatsapp";
|
|
11
|
+
|
|
12
|
+
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
|
13
|
+
? ColumnType<S, I | undefined, U>
|
|
14
|
+
: ColumnType<T, T | undefined, T>;
|
|
15
|
+
|
|
16
|
+
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
|
17
|
+
|
|
18
|
+
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
|
19
|
+
|
|
20
|
+
export type VerificationType = "email" | "password_reset" | "phone";
|
|
21
|
+
|
|
22
|
+
export interface AuthApps {
|
|
23
|
+
id: Generated<Int8>;
|
|
24
|
+
name: string;
|
|
25
|
+
secret: string;
|
|
26
|
+
user_id: Int8;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FactorAuthentication {
|
|
30
|
+
code: string;
|
|
31
|
+
data: string;
|
|
32
|
+
expiry_at: Timestamp;
|
|
33
|
+
id: Generated<Int8>;
|
|
34
|
+
is_verified: Generated<boolean | null>;
|
|
35
|
+
type: FactorAuthType;
|
|
36
|
+
user_id: Int8;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NotificationSettings {
|
|
40
|
+
added_to_dm: Generated<boolean | null>;
|
|
41
|
+
direct_call: Generated<boolean | null>;
|
|
42
|
+
direct_message: Generated<boolean | null>;
|
|
43
|
+
group_dm_mention: Generated<boolean | null>;
|
|
44
|
+
group_dm_message: Generated<boolean | null>;
|
|
45
|
+
id: Generated<Int8>;
|
|
46
|
+
server_everyone_mention: Generated<boolean | null>;
|
|
47
|
+
server_mention: Generated<boolean | null>;
|
|
48
|
+
server_reply: Generated<boolean | null>;
|
|
49
|
+
user_id: Int8;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Passkeys {
|
|
53
|
+
counter: Int8;
|
|
54
|
+
id: Generated<Int8>;
|
|
55
|
+
key_id: Buffer;
|
|
56
|
+
name: string | null;
|
|
57
|
+
public_key: Buffer;
|
|
58
|
+
transports: string[] | null;
|
|
59
|
+
user_id: Int8;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Pgmigrations {
|
|
63
|
+
id: Generated<Int8>;
|
|
64
|
+
name: string;
|
|
65
|
+
run_on: Timestamp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SecureTokens {
|
|
69
|
+
created_at: Generated<Timestamp>;
|
|
70
|
+
expiry_at: Timestamp;
|
|
71
|
+
id: Generated<Int8>;
|
|
72
|
+
token_uuid: string;
|
|
73
|
+
user_id: Int8;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Tokens {
|
|
77
|
+
app: AppType | null;
|
|
78
|
+
created_at: Generated<Timestamp>;
|
|
79
|
+
expiry_at: Timestamp;
|
|
80
|
+
fcm_token: string | null;
|
|
81
|
+
id: Generated<Int8>;
|
|
82
|
+
refresh_token: string;
|
|
83
|
+
user_id: Int8;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Users {
|
|
87
|
+
created_at: Generated<Timestamp>;
|
|
88
|
+
disabled: Generated<boolean | null>;
|
|
89
|
+
icon_url: string | null;
|
|
90
|
+
id: Generated<Int8>;
|
|
91
|
+
is_admin: Generated<boolean | null>;
|
|
92
|
+
password_hash: string;
|
|
93
|
+
username: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UserVerifications {
|
|
97
|
+
created_at: Generated<Timestamp>;
|
|
98
|
+
data: string | null;
|
|
99
|
+
expiry_at: Timestamp;
|
|
100
|
+
id: Generated<Int8>;
|
|
101
|
+
is_verified: Generated<boolean | null>;
|
|
102
|
+
ownership_code: string;
|
|
103
|
+
type: VerificationType;
|
|
104
|
+
user_id: Int8;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DB {
|
|
108
|
+
auth_apps: AuthApps;
|
|
109
|
+
factor_authentication: FactorAuthentication;
|
|
110
|
+
notification_settings: NotificationSettings;
|
|
111
|
+
passkeys: Passkeys;
|
|
112
|
+
pgmigrations: Pgmigrations;
|
|
113
|
+
secure_tokens: SecureTokens;
|
|
114
|
+
tokens: Tokens;
|
|
115
|
+
user_verifications: UserVerifications;
|
|
116
|
+
users: Users;
|
|
117
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PoolConfig as CockroachOptions } from 'pg';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for connecting to a CockroachDB cluster.
|
|
5
|
+
* This extends the 'pg' PoolConfig type for full compatibility.
|
|
6
|
+
*/
|
|
7
|
+
export interface CockroachConfig extends CockroachOptions {
|
|
8
|
+
// You can add custom properties here if needed
|
|
9
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { EstopiaRequest } from '../../types/request';
|
|
2
|
+
import { validateJWTToken } from '../validateJWTToken';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Middleware to require authentication.
|
|
6
|
+
* - Supports Authorization: Bearer <token> header (case-insensitive)
|
|
7
|
+
* - Falls back to cookies.auth_token
|
|
8
|
+
* - On success: sets req.auth = { ...payload, token } and calls next()
|
|
9
|
+
* - On failure: responds with 401 and an appropriate error message
|
|
10
|
+
*/
|
|
11
|
+
export function requireAuth(req: EstopiaRequest, res: any, next: any) {
|
|
12
|
+
// Get Authorization header (many frameworks provide case-insensitive header lookup)
|
|
13
|
+
const authHeader = typeof req.header === 'function' ? req.header('Authorization') : undefined;
|
|
14
|
+
|
|
15
|
+
// Extract token from "Bearer <token>" or use header value directly
|
|
16
|
+
let token: string | undefined;
|
|
17
|
+
if (typeof authHeader === 'string' && authHeader.trim() !== '') {
|
|
18
|
+
const bearerMatch = authHeader.match(/^\s*Bearer\s+(.+)\s*$/i);
|
|
19
|
+
token = bearerMatch ? bearerMatch[1] : authHeader.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback to cookie
|
|
23
|
+
if (!token && req && req.cookies && typeof req.cookies.auth_token === 'string') {
|
|
24
|
+
token = req.cookies.auth_token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!token) {
|
|
28
|
+
return res.status(401).json({ error: 'Missing authorization token' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const payload = validateJWTToken(token);
|
|
33
|
+
if (!payload) {
|
|
34
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Attach auth info to request
|
|
38
|
+
req.auth = { ...payload, token };
|
|
39
|
+
return next();
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
// Log warning if logger available, otherwise fallback to console
|
|
42
|
+
try {
|
|
43
|
+
if (req && req.logger && typeof req.logger.warn === 'function') {
|
|
44
|
+
req.logger.warn('Auth verification failed', err);
|
|
45
|
+
} else {
|
|
46
|
+
// Keep message concise for logs
|
|
47
|
+
// eslint-disable-next-line no-console
|
|
48
|
+
console.warn('Auth verification failed', err);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// swallow logging errors
|
|
52
|
+
}
|
|
53
|
+
return res.status(401).json({ error: 'Invalid authorization' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Response } from 'express';
|
|
2
|
+
import { EstopiaRequest } from '../../types/request';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import type { DB } from '../../database/dataTypes';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Middleware to require authentication.
|
|
8
|
+
* - Supports Authorization: Bearer <token> header (case-insensitive)
|
|
9
|
+
* - Falls back to cookies.auth_token
|
|
10
|
+
* - On success: sets req.auth = { ...payload, token } and calls next()
|
|
11
|
+
* - On failure: responds with 401 and an appropriate error message
|
|
12
|
+
*/
|
|
13
|
+
export async function requireSecure(req: EstopiaRequest, res: Response, next: any) {
|
|
14
|
+
// This middleware only validates the Secure-Token cookie against the DB.
|
|
15
|
+
// It does NOT require a valid JWT — per request: "it doesn't matter if the JWT token exists".
|
|
16
|
+
|
|
17
|
+
// Read secure token from cookie
|
|
18
|
+
const secureToken = req && req.cookies['Secure-Token'];
|
|
19
|
+
if (!secureToken) {
|
|
20
|
+
return res.status(403).json({ error: 'Missing secure token' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Expect a Kysely DB instance to be available on app.locals.db
|
|
24
|
+
const cockroachPool = req.cockroachPool;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const db = cockroachPool as Kysely<DB>;
|
|
28
|
+
// Query secure_tokens table by token_uuid
|
|
29
|
+
const row = await db.selectFrom('secure_tokens').selectAll().where('token_uuid', '=', secureToken).executeTakeFirst();
|
|
30
|
+
|
|
31
|
+
if (!row) {
|
|
32
|
+
return res.status(403).json({ error: 'Secure token not found' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// expiry_at may be a Date or string depending on driver; normalize
|
|
36
|
+
const expiry = row.expiry_at ? new Date(row.expiry_at as any) : null;
|
|
37
|
+
if (!expiry || expiry.getTime() <= Date.now()) {
|
|
38
|
+
return res.status(403).json({ error: 'Secure token expired' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Attach minimal auth context to request (no JWT required)
|
|
42
|
+
req.auth = { secureToken, userId: row.user_id.toString() };
|
|
43
|
+
return next();
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
console.error('Failed to validate secure token', err?.message || err);
|
|
46
|
+
return res.status(500).json({ error: 'Failed to validate secure token' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
export interface DecodedJWT {
|
|
4
|
+
userId: string;
|
|
5
|
+
jti?: string;
|
|
6
|
+
iat?: number;
|
|
7
|
+
exp?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Keep verification options minimal to allow tokens signed without an explicit issuer in tests.
|
|
11
|
+
const jwtVerifyOptions = {};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verify a JWT and return the decoded payload. Throws on invalid/expired token.
|
|
15
|
+
*/
|
|
16
|
+
export function validateJWTToken(token: string): DecodedJWT {
|
|
17
|
+
const secret = process.env.JWT_SECRET;
|
|
18
|
+
if (!secret) throw new Error('JWT secret not configured');
|
|
19
|
+
|
|
20
|
+
const payload = jwt.verify(token, secret, jwtVerifyOptions) as any;
|
|
21
|
+
const userId = payload?.sub ?? payload?.userId ?? payload?.id ?? null;
|
|
22
|
+
if (!userId) throw new Error('token missing subject');
|
|
23
|
+
|
|
24
|
+
return { userId, jti: payload.jti ?? payload.jti ?? undefined, iat: payload.iat, exp: payload.exp } as DecodedJWT;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Try to verify a token and return the decoded payload, or null if invalid.
|
|
29
|
+
*/
|
|
30
|
+
export function tryValidateJWTToken(token: string): DecodedJWT | null {
|
|
31
|
+
try {
|
|
32
|
+
return validateJWTToken(token);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default validateJWTToken;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { createLogger } from './logging/logger';
|
|
2
|
+
export type { LoggerOptions, LogMeta } from './logging/logger';
|
|
3
|
+
|
|
4
|
+
export { createCockroachDBConnection } from './database/cockroach';
|
|
5
|
+
|
|
6
|
+
// Export the configuration types so consuming services can use them
|
|
7
|
+
export type { CockroachConfig } from './database/types';
|
|
8
|
+
|
|
9
|
+
// Export the database clients for type hinting in other services
|
|
10
|
+
export { Pool as CockroachDBClient } from 'pg';
|
|
11
|
+
export { Client as ScyllaDBClient } from 'cassandra-driver';
|
|
12
|
+
|
|
13
|
+
export { EventPublisher } from './broker/publisher';
|
|
14
|
+
export { EventSubscriber } from './broker/subscriber';
|
|
15
|
+
export type { EventPayloads } from './broker/eventTypes';
|
|
16
|
+
|
|
17
|
+
export type { UserDto } from './types/user';
|
|
18
|
+
export { validateJWTToken, tryValidateJWTToken } from './helpers/validateJWTToken';
|
|
19
|
+
|
|
20
|
+
export type { DB } from './database/dataTypes';
|
|
21
|
+
|
|
22
|
+
export type { EstopiaRequest } from './types/request';
|
|
23
|
+
export { requireAuth } from './helpers/middlware/auth';
|
|
24
|
+
export { requireSecure } from './helpers/middlware/secure';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { Logger } from '../types/request';
|
|
4
|
+
|
|
5
|
+
interface LoggerOptions {
|
|
6
|
+
serviceName: string;
|
|
7
|
+
logServiceUrl?: string;
|
|
8
|
+
environment?: "development" | "production";
|
|
9
|
+
sendLogs?: boolean; // default true
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LogMeta {
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createLogger(options: LoggerOptions): Logger {
|
|
17
|
+
const {
|
|
18
|
+
serviceName,
|
|
19
|
+
logServiceUrl = process.env.LOG_SERVICE_URL || "http://localhost:4000/logs",
|
|
20
|
+
environment = process.env.NODE_ENV as "development" | "production" || "development",
|
|
21
|
+
sendLogs = true,
|
|
22
|
+
} = options;
|
|
23
|
+
|
|
24
|
+
let requestId: string | null = null;
|
|
25
|
+
let requestURL: string | null = null;
|
|
26
|
+
let requestMethod: string | null = null;
|
|
27
|
+
let requestUserId: string | null = null;
|
|
28
|
+
let requesterType: 'user' | 'internal' | null = null;
|
|
29
|
+
|
|
30
|
+
const logger =
|
|
31
|
+
environment === "development"
|
|
32
|
+
? pino({ name: serviceName, level: 'debug' } as any)
|
|
33
|
+
: pino({
|
|
34
|
+
name: serviceName,
|
|
35
|
+
level: 'info',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const sendLog = async (level: string, message: string, meta: LogMeta = {}) => {
|
|
39
|
+
if (!sendLogs) return;
|
|
40
|
+
try {
|
|
41
|
+
if (requestId) {
|
|
42
|
+
meta.requestId = requestId;
|
|
43
|
+
}
|
|
44
|
+
if (requestURL) {
|
|
45
|
+
meta.requestURL = requestURL;
|
|
46
|
+
}
|
|
47
|
+
if (requestMethod) {
|
|
48
|
+
meta.requestMethod = requestMethod;
|
|
49
|
+
}
|
|
50
|
+
if (requestUserId) {
|
|
51
|
+
meta.requestUserId = requestUserId;
|
|
52
|
+
}
|
|
53
|
+
if (requesterType) {
|
|
54
|
+
meta.requesterType = requesterType;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await axios.post(
|
|
58
|
+
logServiceUrl,
|
|
59
|
+
{
|
|
60
|
+
service: serviceName,
|
|
61
|
+
level,
|
|
62
|
+
message,
|
|
63
|
+
meta,
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
},
|
|
66
|
+
{ timeout: 2000 }
|
|
67
|
+
);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Only warn locally, don’t crash
|
|
70
|
+
logger.warn({ err }, "Failed to send log to remote service");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const logWrapper: Logger = {
|
|
75
|
+
info: (msg: string, meta?: LogMeta) => {
|
|
76
|
+
logger.info(meta, msg);
|
|
77
|
+
sendLog("info", msg, meta);
|
|
78
|
+
},
|
|
79
|
+
error: (msg: string, meta?: LogMeta) => {
|
|
80
|
+
logger.error(meta, msg);
|
|
81
|
+
sendLog("error", msg, meta);
|
|
82
|
+
},
|
|
83
|
+
warn: (msg: string, meta?: LogMeta) => {
|
|
84
|
+
logger.warn(meta, msg);
|
|
85
|
+
sendLog("warn", msg, meta);
|
|
86
|
+
},
|
|
87
|
+
debug: (msg: string, meta?: LogMeta) => {
|
|
88
|
+
logger.debug(meta, msg);
|
|
89
|
+
sendLog("debug", msg, meta);
|
|
90
|
+
},
|
|
91
|
+
setRequest: (requesterId: string | null, url: string | null, method: string | null, userId: string | null, type: 'user' | 'internal' | null) => {
|
|
92
|
+
requestId = requesterId;
|
|
93
|
+
requestURL = url;
|
|
94
|
+
requestMethod = method;
|
|
95
|
+
requestUserId = userId;
|
|
96
|
+
requesterType = type;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return logWrapper;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type { LoggerOptions, LogMeta };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Enums
|
|
2
|
+
enum UserRole {
|
|
3
|
+
Member = 'MEMBER',
|
|
4
|
+
Admin = 'ADMIN',
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
enum ReportStatus {
|
|
8
|
+
Open = 'OPEN',
|
|
9
|
+
Reviewed = 'REVIEWED',
|
|
10
|
+
Closed = 'CLOSED',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
enum ReportEntityType {
|
|
14
|
+
User = 'USER',
|
|
15
|
+
Server = 'SERVER',
|
|
16
|
+
Channel = 'CHANNEL',
|
|
17
|
+
Message = 'MESSAGE',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
enum TicketStatus {
|
|
21
|
+
Open = 'OPEN',
|
|
22
|
+
Assigned = 'ASSIGNED',
|
|
23
|
+
Closed = 'CLOSED',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
enum DoseStatus {
|
|
27
|
+
Taken = 'TAKEN',
|
|
28
|
+
Skipped = 'SKIPPED',
|
|
29
|
+
Pending = 'PENDING',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
enum messageType {
|
|
33
|
+
Channel = 'CHANNEL',
|
|
34
|
+
Direct = 'DIRECT',
|
|
35
|
+
Ticket = 'TICKET',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
UserRole,
|
|
40
|
+
ReportStatus,
|
|
41
|
+
ReportEntityType,
|
|
42
|
+
TicketStatus,
|
|
43
|
+
DoseStatus,
|
|
44
|
+
messageType
|
|
45
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UserRole } from "./ENUMS.js";
|
|
2
|
+
|
|
3
|
+
// /directs
|
|
4
|
+
export interface DirectMessageDto {
|
|
5
|
+
id: string;
|
|
6
|
+
isGroup: boolean;
|
|
7
|
+
name: string;
|
|
8
|
+
creatorId: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DirectMessageMemberDto {
|
|
12
|
+
id: string;
|
|
13
|
+
userId: number;
|
|
14
|
+
role: UserRole;
|
|
15
|
+
isApproved: boolean;
|
|
16
|
+
joinedAt: Date;
|
|
17
|
+
directId: number;
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DoseStatus } from "./ENUMS.js";
|
|
2
|
+
|
|
3
|
+
// /medical
|
|
4
|
+
export interface MedicationDto {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: number;
|
|
7
|
+
name: string;
|
|
8
|
+
dosage: string;
|
|
9
|
+
startDate: Date;
|
|
10
|
+
endDate?: Date;
|
|
11
|
+
notes?: string;
|
|
12
|
+
quantity?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MedicationRefillDto {
|
|
16
|
+
id: string;
|
|
17
|
+
medicationId: number;
|
|
18
|
+
amount: number;
|
|
19
|
+
date: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MedicationTimeDto {
|
|
23
|
+
id: string;
|
|
24
|
+
medicationId: number;
|
|
25
|
+
time: string; // e.g., "08:00"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TakenDoseDto {
|
|
29
|
+
id: string;
|
|
30
|
+
userId: number;
|
|
31
|
+
medId: number;
|
|
32
|
+
scheduledTime: Date;
|
|
33
|
+
takenTime?: Date;
|
|
34
|
+
dosage: string;
|
|
35
|
+
status: DoseStatus;
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { messageType } from "./ENUMS.js";
|
|
2
|
+
|
|
3
|
+
// /messages
|
|
4
|
+
export interface MessageDto {
|
|
5
|
+
id: string;
|
|
6
|
+
type: messageType;
|
|
7
|
+
directId?: number;
|
|
8
|
+
channelId?: number;
|
|
9
|
+
ticketId?: number;
|
|
10
|
+
userId: number;
|
|
11
|
+
content: string;
|
|
12
|
+
mentionEveryone: boolean;
|
|
13
|
+
replyTo?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MessageMediaDto {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string; // e.g., "image/png"
|
|
19
|
+
size: number;
|
|
20
|
+
messageId: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MessageMentionDto {
|
|
24
|
+
id: string;
|
|
25
|
+
userId: number;
|
|
26
|
+
messageId: number;
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Request } from 'express';
|
|
2
|
+
import { Kysely } from "kysely";
|
|
3
|
+
import { DB } from "../database/dataTypes";
|
|
4
|
+
import { LogMeta } from '../logging/logger';
|
|
5
|
+
|
|
6
|
+
export interface EstopiaRequest extends Request {
|
|
7
|
+
auth?: {
|
|
8
|
+
userId: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
secureToken?: string;
|
|
11
|
+
},
|
|
12
|
+
logger: Logger;
|
|
13
|
+
cockroachPool?: Kysely<DB>;
|
|
14
|
+
publisher?: any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Logger {
|
|
18
|
+
info(msg: string, meta?: LogMeta): void;
|
|
19
|
+
error(msg: string, meta?: LogMeta): void;
|
|
20
|
+
warn(msg: string, meta?: LogMeta): void;
|
|
21
|
+
debug(msg: string, meta?: LogMeta): void;
|
|
22
|
+
setRequest(requesterId: string | null, url: string | null, method: string | null, userId: string | null, type: 'user' | 'internal' | null): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { UserRole } from "./ENUMS.js";
|
|
2
|
+
|
|
3
|
+
// /servers
|
|
4
|
+
export interface ServerDto {
|
|
5
|
+
id: string;
|
|
6
|
+
ownerId: number;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
isPrivate: boolean;
|
|
10
|
+
isVerified: boolean;
|
|
11
|
+
icon: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ServerMemberDto {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: number;
|
|
17
|
+
serverId: number;
|
|
18
|
+
role: UserRole;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ChannelDto {
|
|
22
|
+
id: string;
|
|
23
|
+
serverId: number;
|
|
24
|
+
name: string;
|
|
25
|
+
channelType: string;
|
|
26
|
+
isStaffViewOnly: boolean;
|
|
27
|
+
isStaffTextOnly: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ServerBanDto {
|
|
31
|
+
id: string;
|
|
32
|
+
banningId: number;
|
|
33
|
+
bannedId: number;
|
|
34
|
+
note: string;
|
|
35
|
+
serverId: number;
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface SendPushNotificationRequest {
|
|
2
|
+
userId: string;
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
appType: 'medical' | 'chat';
|
|
6
|
+
// Optional data to send with the notification,
|
|
7
|
+
// e.g., { channelId: 123 }
|
|
8
|
+
data?: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export enum EmailTemplate {
|
|
12
|
+
AccountVerification = 'ACCOUNT_VERIFICATION',
|
|
13
|
+
PasswordReset = 'PASSWORD_RESET',
|
|
14
|
+
FactorVerifcation = 'FACTOR_VERIFICATION',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SendEmailRequest {
|
|
18
|
+
to: string; // The recipient's email address
|
|
19
|
+
template: EmailTemplate;
|
|
20
|
+
// Dynamic data for the template, e.g., { verificationLink: '...' }
|
|
21
|
+
context: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RequestUploadUrlRequest {
|
|
25
|
+
// e.g., 'image/png' or 'video/mp4'
|
|
26
|
+
mimeType: string;
|
|
27
|
+
// Where the file will be used
|
|
28
|
+
context: 'profile-icon' | 'chat-attachment' | 'server-icon';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RequestUploadUrlResponse {
|
|
32
|
+
/**
|
|
33
|
+
* The secure, pre-signed URL where the client should upload the file.
|
|
34
|
+
*/
|
|
35
|
+
uploadUrl: string;
|
|
36
|
+
/**
|
|
37
|
+
* The final URL to access the file after the upload is complete.
|
|
38
|
+
*/
|
|
39
|
+
accessUrl: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReportEntityType, ReportStatus, TicketStatus } from "./ENUMS.js";
|
|
2
|
+
|
|
3
|
+
// /tickets & /reports
|
|
4
|
+
export interface TicketDto {
|
|
5
|
+
id: string;
|
|
6
|
+
discordId?: number;
|
|
7
|
+
status: TicketStatus;
|
|
8
|
+
title: string;
|
|
9
|
+
assignedTo?: number;
|
|
10
|
+
assignedBy?: number;
|
|
11
|
+
closedBy?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TicketMessageDto {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: number;
|
|
17
|
+
ticketId: number;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReportDto {
|
|
22
|
+
id: string;
|
|
23
|
+
reporterId: number;
|
|
24
|
+
entityType: ReportEntityType;
|
|
25
|
+
entityId: number;
|
|
26
|
+
reason: string; // ENUM
|
|
27
|
+
description: string;
|
|
28
|
+
status: ReportStatus;
|
|
29
|
+
reviewedBy?: number;
|
|
30
|
+
reviewedAt?: Date;
|
|
31
|
+
reportedAt: Date;
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// /user
|
|
2
|
+
export interface UserDto {
|
|
3
|
+
id: string;
|
|
4
|
+
username: string;
|
|
5
|
+
password: string | null;
|
|
6
|
+
icon: string;
|
|
7
|
+
isAdmin: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PasskeyDto {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
transports: number;
|
|
14
|
+
// Note: keyid, publicKey, and counter are omitted for security.
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthAppDto {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
// Note: secret is omitted for security.
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NotificationSettingsDto {
|
|
24
|
+
id: string;
|
|
25
|
+
userId: number;
|
|
26
|
+
directMessage: boolean;
|
|
27
|
+
groupDMMessage: boolean;
|
|
28
|
+
groupDMMention: boolean;
|
|
29
|
+
addedToDM: boolean;
|
|
30
|
+
serverEveryoneMention: boolean;
|
|
31
|
+
serverMention: boolean;
|
|
32
|
+
serverReply: boolean;
|
|
33
|
+
directCall: boolean;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { validateJWTToken, tryValidateJWTToken } from '../src/helpers/validateJWTToken';
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('verifyJWTToken helper', () => {
|
|
10
|
+
it('verifies a valid token and returns payload', () => {
|
|
11
|
+
process.env.JWT_SECRET = 'test_jwt_secret';
|
|
12
|
+
const token = jwt.sign({ sub: 'user-1' }, process.env.JWT_SECRET!, { issuer: 'estopia-auth' });
|
|
13
|
+
const decoded = validateJWTToken(token);
|
|
14
|
+
expect(decoded).toHaveProperty('userId', 'user-1');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('tryValidateJWTToken returns null for invalid token', () => {
|
|
18
|
+
process.env.JWT_SECRET = 'test_jwt_secret';
|
|
19
|
+
const decoded = tryValidateJWTToken('not-a-token');
|
|
20
|
+
expect(decoded).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('validateJWTToken throws when secret not configured', () => {
|
|
24
|
+
delete process.env.JWT_SECRET;
|
|
25
|
+
expect(() => validateJWTToken('anything')).toThrow(/JWT secret not configured/);
|
|
26
|
+
});
|
|
27
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Visit https://aka.ms/tsconfig to read more about this file
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// File Layout
|
|
5
|
+
// "rootDir": "./src",
|
|
6
|
+
// "outDir": "./dist",
|
|
7
|
+
|
|
8
|
+
// Environment Settings
|
|
9
|
+
// See also https://aka.ms/tsconfig/module
|
|
10
|
+
"module": "nodenext",
|
|
11
|
+
"target": "esnext",
|
|
12
|
+
"types": [
|
|
13
|
+
"node"
|
|
14
|
+
],
|
|
15
|
+
// For nodejs:
|
|
16
|
+
// "lib": ["esnext"],
|
|
17
|
+
// "types": ["node"],
|
|
18
|
+
// and npm install -D @types/node
|
|
19
|
+
|
|
20
|
+
// Other Outputs
|
|
21
|
+
"sourceMap": true,
|
|
22
|
+
"declaration": true,
|
|
23
|
+
"declarationMap": true,
|
|
24
|
+
|
|
25
|
+
// Stricter Typechecking Options
|
|
26
|
+
"noUncheckedIndexedAccess": true,
|
|
27
|
+
"exactOptionalPropertyTypes": true,
|
|
28
|
+
|
|
29
|
+
// Style Options
|
|
30
|
+
// "noImplicitReturns": true,
|
|
31
|
+
// "noImplicitOverride": true,
|
|
32
|
+
// "noUnusedLocals": true,
|
|
33
|
+
// "noUnusedParameters": true,
|
|
34
|
+
// "noFallthroughCasesInSwitch": true,
|
|
35
|
+
// "noPropertyAccessFromIndexSignature": true,
|
|
36
|
+
|
|
37
|
+
// Recommended Options
|
|
38
|
+
"strict": true,
|
|
39
|
+
"jsx": "react-jsx",
|
|
40
|
+
"verbatimModuleSyntax": false,
|
|
41
|
+
"isolatedModules": true,
|
|
42
|
+
"noUncheckedSideEffectImports": true,
|
|
43
|
+
"moduleDetection": "force",
|
|
44
|
+
"skipLibCheck": true,
|
|
45
|
+
"allowImportingTsExtensions": true,
|
|
46
|
+
"noEmit": true
|
|
47
|
+
}
|
|
48
|
+
}
|