@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.
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ },
9
+ });