init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
/coverage/
|
||||||
|
/.env
|
||||||
18
.prettierrc.json
Normal file
18
.prettierrc.json
Normal 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
25
.u8.json
Normal 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
51
eslint.config.mjs
Normal 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
48
package.json
Normal 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
5130
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
src/access/access.schemas.ts
Normal file
11
src/access/access.schemas.ts
Normal 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 };
|
||||||
29
src/access/access.session.ts
Normal file
29
src/access/access.session.ts
Normal 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 };
|
||||||
36
src/access/access.token.ts
Normal file
36
src/access/access.token.ts
Normal 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
2
src/access/access.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './access.session.ts';
|
||||||
|
export * from './access.token.ts';
|
||||||
24
src/access/access.utils.ts
Normal file
24
src/access/access.utils.ts
Normal 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
0
src/exports.ts
Normal file
117
src/server/server.ts
Normal file
117
src/server/server.ts
Normal 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
29
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user