This commit is contained in:
Morten Olsen
2025-10-14 23:08:12 +02:00
commit 521ffd395f
14 changed files with 5524 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

18
.prettierrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"bracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"singleAttributePerLine": false
}

25
.u8.json Normal file
View File

@@ -0,0 +1,25 @@
{
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
},
"entries": [
{
"timestamp": "2025-10-14T21:07:08.600Z",
"template": "pkg",
"values": {
"monoRepo": false,
"packageName": "@morten-olsen/backbone",
"packageVersion": "1.0.0"
}
},
{
"timestamp": "2025-10-14T21:07:17.442Z",
"template": "eslint",
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
}
}
]
}

51
eslint.config.mjs Normal file
View File

@@ -0,0 +1,51 @@
import { FlatCompat } from '@eslint/eslintrc';
import importPlugin from 'eslint-plugin-import';
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
const compat = new FlatCompat({
baseDirectory: import.meta.__dirname,
resolvePluginsRelativeTo: import.meta.__dirname,
});
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
eslintConfigPrettier,
{
files: ['**/*.{ts,tsxx}'],
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
rules: {
'import/no-unresolved': 'off',
'import/extensions': ['error', 'ignorePackages'],
'import/exports-last': 'error',
'import/no-default-export': 'error',
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
],
'import/no-duplicates': 'error',
},
},
{
rules: {
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
},
},
{
files: ['**.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
},
},
...compat.extends('plugin:prettier/recommended'),
{
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
},
);

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"type": "module",
"main": "dist/exports.js",
"scripts": {
"test:lint": "eslint",
"build": "tsc --build",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"exports": {
".": "./dist/exports.js"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.37.0",
"@pnpm/find-workspace-packages": "6.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/micromatch": "^4.0.9",
"@types/node": "24.7.2",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.37.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"prettier": "3.6.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.1",
"vitest": "3.2.4"
},
"name": "@morten-olsen/backbone",
"version": "1.0.0",
"imports": {
"#root/*": "./src/*"
},
"dependencies": {
"aedes": "^0.51.3",
"aedes-persistence": "^10.2.2",
"jsonwebtoken": "^9.0.2",
"micromatch": "^4.0.8",
"ws": "^8.18.3",
"zod": "^4.1.12"
}
}

5130
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import z from 'zod';
const statementSchema = z.object({
effect: z.enum(['allow', 'disallow']),
resources: z.array(z.string()),
actions: z.array(z.string()),
});
type Statement = z.infer<typeof statementSchema>;
export type { Statement };
export { statementSchema };

View File

@@ -0,0 +1,29 @@
import type { Statement } from './access.schemas.ts';
import { validate } from './access.utils.ts';
type SessionOptions = {
statements: Statement[];
};
type ValidateOptions = {
action: string;
resource: string;
};
class Session {
#options: SessionOptions;
constructor(options: SessionOptions) {
this.#options = options;
}
public validate = (options: ValidateOptions) => {
const { statements } = this.#options;
return validate({
...options,
statements,
});
};
}
export { Session };

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
import jwt from 'jsonwebtoken';
import { statementSchema } from './access.schemas.ts';
type AccessTokensOptions = {
secret: string | Buffer;
};
const tokenBodySchema = z.object({
statements: z.array(statementSchema),
});
type TokenBody = z.infer<typeof tokenBodySchema>;
class AccessTokens {
#options: AccessTokensOptions;
constructor(options: AccessTokensOptions) {
this.#options = options;
}
public generate = (options: TokenBody) => {
const { secret } = this.#options;
const token = jwt.sign(options, secret);
return token;
};
public validate = (token: string) => {
const { secret } = this.#options;
const data = jwt.verify(token, secret);
const parsed = tokenBodySchema.parse(data);
return parsed;
};
}
export { AccessTokens };

2
src/access/access.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './access.session.ts';
export * from './access.token.ts';

View File

@@ -0,0 +1,24 @@
import micromatch from 'micromatch';
import type { Statement } from './access.schemas.ts';
type ValidateOptions = {
action: string;
resource: string;
statements: Statement[];
};
const validate = (options: ValidateOptions) => {
const { statements, resource, action } = options;
const matches = statements.filter(
(statement) => micromatch.isMatch(resource, statement.resources) && micromatch.isMatch(action, statement.actions),
);
if (matches.length === 0) {
return false;
}
if (matches.find((statement) => statement.effect === 'disallow')) {
return false;
}
return true;
};
export { validate };

0
src/exports.ts Normal file
View File

117
src/server/server.ts Normal file
View File

@@ -0,0 +1,117 @@
import http from 'node:http';
import tcp from 'node:net';
import { WebSocketServer, createWebSocketStream } from 'ws';
import {
createBroker,
type AuthenticateHandler,
type AuthorizeForwardHandler,
type AuthorizePublishHandler,
type AuthorizeSubscribeHandler,
type PublishedHandler,
} from 'aedes';
import { AedesMemoryPersistence } from 'aedes-persistence';
import { Session } from '../access/access.session.ts';
import type { AccessTokens } from '#root/access/access.token.ts';
type Aedes = ReturnType<typeof createBroker>;
declare module 'aedes' {
export interface Client {
session: Session;
}
}
type MqttServerOptions = {
accessTokens: AccessTokens;
};
class MqttServer {
#options: MqttServerOptions;
#server: Aedes;
#http?: http.Server;
#tcp?: tcp.Server;
constructor(options: MqttServerOptions) {
this.#options = options;
this.#server = createBroker({
persistence: new AedesMemoryPersistence(),
authenticate: this.#authenticate,
authorizePublish: this.#authorizePublish,
authorizeSubscribe: this.#authorizeSubscribe,
authorizeForward: this.#authorizeForward,
published: this.#published,
});
}
#authenticate: AuthenticateHandler = (client, _username, password, callback) => {
if (!password) {
throw new Error('unauthorized');
}
const { accessTokens } = this.#options;
const auth = accessTokens.validate(password.toString('utf8'));
client.session = new Session({
statements: auth.statements,
});
callback(null, true);
};
#authorizePublish: AuthorizePublishHandler = (client, packet, callback) => {
const authorized = client?.session.validate({
action: 'mqtt:publish',
resource: `mqtt:${packet.topic}`,
});
if (!authorized) {
return callback(new Error('unauthorized'));
}
callback();
};
#authorizeSubscribe: AuthorizeSubscribeHandler = (client, subscription, callback) => {
const authorized = client?.session.validate({
action: 'mqtt:subscribe',
resource: `mqtt:${subscription.topic}`,
});
if (!authorized) {
return callback(new Error('unauthorized'), null);
}
callback(null, subscription);
};
#authorizeForward: AuthorizeForwardHandler = (client, packet) => {
const authorized = client.session.validate({
action: 'mqtt:forward',
resource: `mqtt:${packet.topic}`,
});
if (!authorized) {
return;
}
return packet;
};
#published: PublishedHandler = (_packet, _client, callback) => {
callback();
};
public getHttpServer = () => {
if (!this.#http) {
this.#http = http.createServer();
const wss = new WebSocketServer({
server: this.#http,
});
wss.on('connection', (websocket, req) => {
const stream = createWebSocketStream(websocket);
this.#server.handle(stream, req);
});
}
return this.#http;
};
public getTcpServer = () => {
if (!this.#tcp) {
this.#tcp = tcp.createServer(this.#server.handle);
}
return this.#tcp;
};
}
export { MqttServer };

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"paths": {
"#root/*": [
"./src/*"
]
}
},
"include": [
"src/**/*.ts"
]
}