@empyria/moleculer 0.1.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/.oxfmtrc.json +9 -0
- package/AGENTS.md +41 -0
- package/Connector.js +54 -0
- package/MetaGuard.middleware.js +67 -0
- package/Mixins.js +2 -0
- package/README.md +90 -0
- package/coverage/lcov.info +216 -0
- package/mixins/Base.mixin.js +58 -0
- package/mixins/Connector.mixin.js +50 -0
- package/package.json +41 -0
- package/test/Base.mixin.test.js +30 -0
- package/test/Connector.test.js +35 -0
- package/test/Context.test.js +106 -0
- package/test/Crypto.test.js +73 -0
- package/test/MetaGuard.test.js +108 -0
- package/util/Context.js +93 -0
- package/util/Crypto.js +121 -0
package/.oxfmtrc.json
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Moleculer helpers for **Principia**, a nanoservice framework built primarily on Bun:
|
|
4
|
+
`BaseMixin` (per-service Principia meta stamping), `MetaGuard` (a middleware guarding
|
|
5
|
+
actions/events against unwanted cross-federation access), and lower-level `Connector.js`/
|
|
6
|
+
`util/` helpers.
|
|
7
|
+
|
|
8
|
+
## Runtime
|
|
9
|
+
|
|
10
|
+
- Requires Bun `>=1.4.0` or Node.js `>=26`, inherited from `@empyria/classification`'s use
|
|
11
|
+
of native `Temporal`. Both `@empyria/classification` and `@empyria/common` are **git
|
|
12
|
+
dependencies** — this package only sees their pushed commits, not local working-tree changes
|
|
13
|
+
in sibling repos.
|
|
14
|
+
- Plain ESM, no TypeScript, no build step.
|
|
15
|
+
- Relative imports must include explicit `.js` extensions — Bun tolerates missing ones, Node's
|
|
16
|
+
ESM resolver doesn't.
|
|
17
|
+
|
|
18
|
+
## Known incomplete module
|
|
19
|
+
|
|
20
|
+
`mixins/Connector.mixin.js` imports `@empyria/redis` and `@empyria/mongo`, which aren't
|
|
21
|
+
published as standalone packages yet (they still live inside the monolithic `principia` repo's
|
|
22
|
+
`libs/`). It is **not** re-exported from [Mixins.js](./Mixins.js) — re-adding that export line
|
|
23
|
+
before those two packages exist would break every consumer's import of this whole package. Only
|
|
24
|
+
re-export it once both are published; the file itself has a comment marking this.
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
- [Mixins.js](./Mixins.js) is the package entry point (mixins + middleware only).
|
|
29
|
+
- [Connector.js](./Connector.js) (broker start/stop helper) and `util/*.js` are separate
|
|
30
|
+
subpath exports (see `package.json`'s `exports` map) — not re-exported from `Mixins.js`.
|
|
31
|
+
- `MetaGuard.middleware.js`'s `localAction`/`remoteAction` share one `guardAction` helper —
|
|
32
|
+
keep them in sync if you touch the guard logic; don't let them drift back into duplicated code.
|
|
33
|
+
- Tests live in `test/`, flat, one file per module, using `bun:test` — several exercise a real
|
|
34
|
+
`moleculer` `ServiceBroker` rather than mocks (see `Connector.test.js`, `Base.mixin.test.js`).
|
|
35
|
+
A service registered on an already-started broker needs `broker.waitForServices(name)` before
|
|
36
|
+
it's callable — a bare `createService` + immediate `call` will 404.
|
|
37
|
+
|
|
38
|
+
## Style
|
|
39
|
+
|
|
40
|
+
- Formatting is enforced by oxfmt ([.oxfmtrc.json](./.oxfmtrc.json)): tabs, single quotes, no
|
|
41
|
+
semicolons, trailing commas. Run `bun run format:fix` before committing.
|
package/Connector.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ServiceBroker } from 'moleculer'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts a Moleculer broker and waits for any required services to become available.
|
|
5
|
+
* @param {Object} params
|
|
6
|
+
* @param {string} [params.namespace] - Broker namespace.
|
|
7
|
+
* @param {string} [params.nodeID] - Broker node ID.
|
|
8
|
+
* @param {Object} [params.transporter] - Moleculer transporter config.
|
|
9
|
+
* @param {string} [params.logLevel='debug'] - Broker log level.
|
|
10
|
+
* @param {string[]} [params.servicesRequired] - Action/service names to wait for before resolving.
|
|
11
|
+
* @param {number} [params.waitTimeout] - Max time (ms) to wait for `servicesRequired`.
|
|
12
|
+
* @param {number} [params.interval] - Poll interval (ms) while waiting for `servicesRequired`.
|
|
13
|
+
* @returns {Promise<{broker: ServiceBroker, call: (action: string, params?: Object, opts?: {timeout?: number, meta?: Object}) => Promise<*>, stop: () => Promise<void>}>}
|
|
14
|
+
*/
|
|
15
|
+
export const connect = async ({
|
|
16
|
+
namespace,
|
|
17
|
+
nodeID,
|
|
18
|
+
transporter,
|
|
19
|
+
logLevel,
|
|
20
|
+
servicesRequired,
|
|
21
|
+
waitTimeout,
|
|
22
|
+
interval,
|
|
23
|
+
}) => {
|
|
24
|
+
const broker = new ServiceBroker({
|
|
25
|
+
namespace,
|
|
26
|
+
nodeID,
|
|
27
|
+
transporter,
|
|
28
|
+
logLevel: logLevel ?? 'debug',
|
|
29
|
+
hotReload: false,
|
|
30
|
+
metrics: false,
|
|
31
|
+
tracing: false,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
console.log('🔌 Starting Moleculer broker...')
|
|
35
|
+
await broker.start()
|
|
36
|
+
console.log('✅ Moleculer broker connected')
|
|
37
|
+
|
|
38
|
+
if (servicesRequired?.length > 0) {
|
|
39
|
+
await broker.waitForServices(servicesRequired, waitTimeout, interval)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
broker,
|
|
44
|
+
async call(action, params, opts) {
|
|
45
|
+
return broker.call(action, params, {
|
|
46
|
+
timeout: opts?.timeout,
|
|
47
|
+
meta: opts?.meta,
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
async stop() {
|
|
51
|
+
await broker.stop()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { validateContext, getUser } from './util/Context.js'
|
|
2
|
+
|
|
3
|
+
import { PRINCIPIA_FEDERATION_ID, BaseErrors } from '@empyria/common'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the shared guard logic for `localAction`/`remoteAction` hooks: validates the
|
|
7
|
+
* call's context, rejects cross-federation calls to a whitelisted action, and optionally
|
|
8
|
+
* resolves the caller into `ctx.meta.user`.
|
|
9
|
+
* @param {(ctx: Object) => Promise<*>} next - The next handler in the middleware chain.
|
|
10
|
+
* @param {Object} action - The Moleculer action being guarded.
|
|
11
|
+
* @param {boolean} resolveUser - Whether to resolve and attach the caller's user.
|
|
12
|
+
* @param {string[]} whitelistedActions - Action names restricted to same-federation callers.
|
|
13
|
+
* @returns {(ctx: Object) => Promise<*>}
|
|
14
|
+
*/
|
|
15
|
+
function guardAction(next, action, resolveUser, whitelistedActions) {
|
|
16
|
+
return async (ctx) => {
|
|
17
|
+
const { user } = validateContext(ctx, action)
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
whitelistedActions.includes(action.name) &&
|
|
21
|
+
user.federation !== PRINCIPIA_FEDERATION_ID
|
|
22
|
+
) {
|
|
23
|
+
throw new BaseErrors.ServiceVoilation({
|
|
24
|
+
service: `Microservice ${ctx.action?.name || action.name} communication`,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (resolveUser && action.name !== 'v1.Auth.resolveToken') {
|
|
29
|
+
ctx.meta.user = await getUser(ctx, action)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return next(ctx)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Moleculer middleware that guards local/remote actions and local events against
|
|
38
|
+
* unwanted access, validating the call's `ctx.meta` and optionally resolving the caller.
|
|
39
|
+
* @param {Object} options
|
|
40
|
+
* @param {boolean} [options.resolveUser] - Resolve the caller via `v1.Auth.resolveToken` and
|
|
41
|
+
* attach it to `ctx.meta.user` before calling through.
|
|
42
|
+
* @param {string[]} [options.whitelistedActions] - Action names restricted to same-federation
|
|
43
|
+
* callers; cross-federation calls to these are rejected.
|
|
44
|
+
* @returns {Object} A Moleculer middleware object with `localAction`, `remoteAction`, and
|
|
45
|
+
* `localEvent` hooks.
|
|
46
|
+
*/
|
|
47
|
+
export function MetaGuard({ resolveUser, whitelistedActions = [] }) {
|
|
48
|
+
return {
|
|
49
|
+
name: 'MetaGuard',
|
|
50
|
+
|
|
51
|
+
localAction(next, action) {
|
|
52
|
+
return guardAction(next, action, resolveUser, whitelistedActions)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
remoteAction(next, action) {
|
|
56
|
+
return guardAction(next, action, resolveUser, whitelistedActions)
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
localEvent(next, event) {
|
|
60
|
+
return async (ctx) => {
|
|
61
|
+
validateContext(ctx, event)
|
|
62
|
+
|
|
63
|
+
return next(ctx)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
package/Mixins.js
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @empyria/moleculer
|
|
2
|
+
|
|
3
|
+
Moleculer helpers for **Principia**, a nanoservice framework built primarily on Bun:
|
|
4
|
+
a base service mixin, and a context-validating guard middleware against unwanted
|
|
5
|
+
(unauthenticated or cross-federation) access.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Bun `>=1.4.0` or Node.js `>=26`
|
|
10
|
+
- Plain ESM, no build step, no TypeScript
|
|
11
|
+
|
|
12
|
+
Both requirements come from [@empyria/classification](https://github.com/imrefazekas/empyria-classification)
|
|
13
|
+
and [@empyria/common](https://github.com/imrefazekas/empyria-common), which this package
|
|
14
|
+
depends on and which use the native `Temporal` global for all date/time handling.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @empyria/moleculer
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { ServiceBroker } from 'moleculer'
|
|
26
|
+
import { BaseMixin, MetaGuard } from '@empyria/moleculer'
|
|
27
|
+
|
|
28
|
+
const broker = new ServiceBroker({
|
|
29
|
+
middlewares: [MetaGuard({ resolveUser: true, whitelistedActions: ['v1.Internal.action'] })],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
broker.createService({
|
|
33
|
+
name: 'my-service',
|
|
34
|
+
mixins: [BaseMixin],
|
|
35
|
+
actions: {
|
|
36
|
+
hello(ctx) {
|
|
37
|
+
return `Hello, ${ctx.meta.user.actor}`
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Everything is re-exported from the package root via [Mixins.js](./Mixins.js). The broker
|
|
44
|
+
connection helper and lower-level utilities are separate subpath exports, importable directly:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { connect } from '@empyria/moleculer/Connector.js'
|
|
48
|
+
import { hashPassword, verifyPassword } from '@empyria/moleculer/util/Crypto.js'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Modules
|
|
52
|
+
|
|
53
|
+
| Module | Purpose |
|
|
54
|
+
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| [Mixins.js](./Mixins.js) | Package entry point; re-exports `BaseMixin` and `MetaGuard`. |
|
|
56
|
+
| [MetaGuard.middleware.js](./MetaGuard.middleware.js) | `MetaGuard` — a Moleculer middleware guarding actions/events against unwanted access, with optional caller resolution. |
|
|
57
|
+
| [mixins/Base.mixin.js](./mixins/Base.mixin.js) | `BaseMixin` — stamps a Principia meta (actor/federation/flow/process IDs) on service start, and exposes the package version. |
|
|
58
|
+
| [mixins/Connector.mixin.js](./mixins/Connector.mixin.js) | `ConnectorMixin` — adds optional Redis/Mongo setup. **Not yet exported from `Mixins.js`** — see below. |
|
|
59
|
+
| [Connector.js](./Connector.js) | `connect` — starts a Moleculer broker and waits for required services. |
|
|
60
|
+
| [util/Context.js](./util/Context.js) | Context helpers: `getParams`, `getUser`, `validateContext`, `actionStamped`, `timeStamped`, `checkDefined`. |
|
|
61
|
+
| [util/Crypto.js](./util/Crypto.js) | Hashing, Ed25519 signing/verification, and password hashing (`hashPassword`/`verifyPassword`). |
|
|
62
|
+
|
|
63
|
+
### Known limitation: `ConnectorMixin`
|
|
64
|
+
|
|
65
|
+
`mixins/Connector.mixin.js` imports `@empyria/redis` and `@empyria/mongo`, which aren't
|
|
66
|
+
published as standalone packages yet. It's intentionally left out of the [Mixins.js](./Mixins.js)
|
|
67
|
+
barrel so the rest of the package stays importable — until those two are published, use it only
|
|
68
|
+
via a direct subpath import (`@empyria/moleculer/mixins/Connector.mixin.js`), where it will
|
|
69
|
+
fail to resolve.
|
|
70
|
+
|
|
71
|
+
Every exported function is documented with JSDoc directly in its source file — hovering
|
|
72
|
+
a function in VSCode or Zed shows its parameters and return type without any extra
|
|
73
|
+
tooling, since both editors read JSDoc from plain `.js` files automatically.
|
|
74
|
+
|
|
75
|
+
Tests live under [test/](./test/), one file per module, separate from the sources. Several
|
|
76
|
+
exercise a real `moleculer` `ServiceBroker` rather than mocks.
|
|
77
|
+
|
|
78
|
+
## Scripts
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun run format # check formatting (oxfmt)
|
|
82
|
+
bun run format:fix # apply formatting
|
|
83
|
+
bun run lint # lint (oxlint)
|
|
84
|
+
bun run lint:fix # lint and fix
|
|
85
|
+
bun run test # run tests with coverage
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT © Imre Fazekas
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
TN:
|
|
2
|
+
SF:Connector.js
|
|
3
|
+
FNF:5
|
|
4
|
+
FNH:5
|
|
5
|
+
DA:1,42
|
|
6
|
+
DA:15,25
|
|
7
|
+
DA:16,12
|
|
8
|
+
DA:17,9
|
|
9
|
+
DA:18,14
|
|
10
|
+
DA:19,11
|
|
11
|
+
DA:20,19
|
|
12
|
+
DA:21,14
|
|
13
|
+
DA:22,13
|
|
14
|
+
DA:23,3
|
|
15
|
+
DA:24,38
|
|
16
|
+
DA:25,14
|
|
17
|
+
DA:26,11
|
|
18
|
+
DA:27,16
|
|
19
|
+
DA:28,34
|
|
20
|
+
DA:29,21
|
|
21
|
+
DA:30,19
|
|
22
|
+
DA:31,16
|
|
23
|
+
DA:32,5
|
|
24
|
+
DA:34,59
|
|
25
|
+
DA:35,23
|
|
26
|
+
DA:36,51
|
|
27
|
+
DA:38,38
|
|
28
|
+
DA:39,72
|
|
29
|
+
DA:40,2
|
|
30
|
+
DA:42,12
|
|
31
|
+
DA:43,10
|
|
32
|
+
DA:44,30
|
|
33
|
+
DA:45,44
|
|
34
|
+
DA:46,31
|
|
35
|
+
DA:47,22
|
|
36
|
+
DA:48,6
|
|
37
|
+
DA:50,9
|
|
38
|
+
DA:51,27
|
|
39
|
+
DA:53,2
|
|
40
|
+
LF:35
|
|
41
|
+
LH:35
|
|
42
|
+
end_of_record
|
|
43
|
+
TN:
|
|
44
|
+
SF:MetaGuard.middleware.js
|
|
45
|
+
FNF:8
|
|
46
|
+
FNH:7
|
|
47
|
+
DA:1,61
|
|
48
|
+
DA:3,71
|
|
49
|
+
DA:15,51
|
|
50
|
+
DA:16,19
|
|
51
|
+
DA:17,50
|
|
52
|
+
DA:19,4
|
|
53
|
+
DA:20,44
|
|
54
|
+
DA:21,44
|
|
55
|
+
DA:22,7
|
|
56
|
+
DA:23,47
|
|
57
|
+
DA:24,78
|
|
58
|
+
DA:25,2
|
|
59
|
+
DA:26,4
|
|
60
|
+
DA:28,65
|
|
61
|
+
DA:29,47
|
|
62
|
+
DA:30,4
|
|
63
|
+
DA:32,18
|
|
64
|
+
DA:47,51
|
|
65
|
+
DA:48,12
|
|
66
|
+
DA:49,21
|
|
67
|
+
DA:51,22
|
|
68
|
+
DA:52,69
|
|
69
|
+
DA:55,0
|
|
70
|
+
DA:56,4
|
|
71
|
+
DA:59,21
|
|
72
|
+
DA:60,24
|
|
73
|
+
DA:61,36
|
|
74
|
+
DA:63,18
|
|
75
|
+
DA:66,1
|
|
76
|
+
LF:29
|
|
77
|
+
LH:28
|
|
78
|
+
end_of_record
|
|
79
|
+
TN:
|
|
80
|
+
SF:mixins/Base.mixin.js
|
|
81
|
+
FNF:3
|
|
82
|
+
FNH:3
|
|
83
|
+
DA:1,25
|
|
84
|
+
DA:2,29
|
|
85
|
+
DA:3,41
|
|
86
|
+
DA:5,41
|
|
87
|
+
DA:7,99
|
|
88
|
+
DA:9,50
|
|
89
|
+
DA:10,43
|
|
90
|
+
DA:12,26
|
|
91
|
+
DA:13,67
|
|
92
|
+
DA:14,11
|
|
93
|
+
DA:21,27
|
|
94
|
+
DA:22,20
|
|
95
|
+
DA:24,18
|
|
96
|
+
DA:26,8
|
|
97
|
+
DA:27,97
|
|
98
|
+
DA:29,58
|
|
99
|
+
DA:31,33
|
|
100
|
+
DA:32,36
|
|
101
|
+
DA:33,42
|
|
102
|
+
DA:34,27
|
|
103
|
+
DA:35,30
|
|
104
|
+
DA:36,35
|
|
105
|
+
DA:37,39
|
|
106
|
+
DA:38,8
|
|
107
|
+
DA:41,13
|
|
108
|
+
DA:45,10
|
|
109
|
+
DA:46,16
|
|
110
|
+
DA:48,4
|
|
111
|
+
DA:50,13
|
|
112
|
+
DA:54,10
|
|
113
|
+
DA:55,29
|
|
114
|
+
DA:57,2
|
|
115
|
+
DA:58,1
|
|
116
|
+
LF:33
|
|
117
|
+
LH:33
|
|
118
|
+
end_of_record
|
|
119
|
+
TN:
|
|
120
|
+
SF:util/Context.js
|
|
121
|
+
FNF:6
|
|
122
|
+
FNH:6
|
|
123
|
+
DA:1,63
|
|
124
|
+
DA:2,47
|
|
125
|
+
DA:9,14
|
|
126
|
+
DA:10,80
|
|
127
|
+
DA:11,47
|
|
128
|
+
DA:12,18
|
|
129
|
+
DA:22,22
|
|
130
|
+
DA:23,37
|
|
131
|
+
DA:24,25
|
|
132
|
+
DA:26,35
|
|
133
|
+
DA:27,64
|
|
134
|
+
DA:39,22
|
|
135
|
+
DA:40,38
|
|
136
|
+
DA:42,53
|
|
137
|
+
DA:44,50
|
|
138
|
+
DA:45,41
|
|
139
|
+
DA:46,76
|
|
140
|
+
DA:47,2
|
|
141
|
+
DA:48,2
|
|
142
|
+
DA:50,26
|
|
143
|
+
DA:59,15
|
|
144
|
+
DA:60,12
|
|
145
|
+
DA:61,22
|
|
146
|
+
DA:62,32
|
|
147
|
+
DA:63,20
|
|
148
|
+
DA:64,24
|
|
149
|
+
DA:65,30
|
|
150
|
+
DA:66,36
|
|
151
|
+
DA:67,2
|
|
152
|
+
DA:75,17
|
|
153
|
+
DA:76,45
|
|
154
|
+
DA:77,14
|
|
155
|
+
DA:87,17
|
|
156
|
+
DA:88,35
|
|
157
|
+
DA:90,91
|
|
158
|
+
DA:92,10
|
|
159
|
+
LF:36
|
|
160
|
+
LH:36
|
|
161
|
+
end_of_record
|
|
162
|
+
TN:
|
|
163
|
+
SF:util/Crypto.js
|
|
164
|
+
FNF:10
|
|
165
|
+
FNH:10
|
|
166
|
+
DA:1,157
|
|
167
|
+
DA:12,38
|
|
168
|
+
DA:14,48
|
|
169
|
+
DA:16,38
|
|
170
|
+
DA:23,14
|
|
171
|
+
DA:24,80
|
|
172
|
+
DA:35,43
|
|
173
|
+
DA:36,48
|
|
174
|
+
DA:37,88
|
|
175
|
+
DA:44,11
|
|
176
|
+
DA:45,67
|
|
177
|
+
DA:48,84
|
|
178
|
+
DA:49,87
|
|
179
|
+
DA:51,12
|
|
180
|
+
DA:52,43
|
|
181
|
+
DA:53,38
|
|
182
|
+
DA:54,2
|
|
183
|
+
DA:63,37
|
|
184
|
+
DA:64,51
|
|
185
|
+
DA:65,51
|
|
186
|
+
DA:66,72
|
|
187
|
+
DA:67,41
|
|
188
|
+
DA:68,41
|
|
189
|
+
DA:69,18
|
|
190
|
+
DA:70,15
|
|
191
|
+
DA:71,5
|
|
192
|
+
DA:73,52
|
|
193
|
+
DA:74,33
|
|
194
|
+
DA:84,50
|
|
195
|
+
DA:85,51
|
|
196
|
+
DA:86,53
|
|
197
|
+
DA:87,50
|
|
198
|
+
DA:88,64
|
|
199
|
+
DA:89,39
|
|
200
|
+
DA:90,41
|
|
201
|
+
DA:91,18
|
|
202
|
+
DA:92,14
|
|
203
|
+
DA:93,5
|
|
204
|
+
DA:95,51
|
|
205
|
+
DA:103,18
|
|
206
|
+
DA:104,47
|
|
207
|
+
DA:105,52
|
|
208
|
+
DA:106,40
|
|
209
|
+
DA:116,24
|
|
210
|
+
DA:117,49
|
|
211
|
+
DA:118,63
|
|
212
|
+
DA:119,52
|
|
213
|
+
DA:120,46
|
|
214
|
+
LF:48
|
|
215
|
+
LH:48
|
|
216
|
+
end_of_record
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { randomUUID } from 'crypto'
|
|
4
|
+
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
|
|
7
|
+
import { PRINCIPIA_FEDERATION_ID, MOLECULER_SERVICE_ROLE, moleculerMeta } from '@empyria/common'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = path.dirname(__filename)
|
|
11
|
+
|
|
12
|
+
const VERSION = JSON.parse(
|
|
13
|
+
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
|
|
14
|
+
).version
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base Moleculer mixin every Principia service builds on. On `started`, stamps a
|
|
18
|
+
* Principia-flavored `meta` (actor/federation/flow/process IDs) onto the service, and
|
|
19
|
+
* exposes the running package version as both a method and an action.
|
|
20
|
+
*/
|
|
21
|
+
export const BaseMixin = {
|
|
22
|
+
name: 'BaseMixin',
|
|
23
|
+
|
|
24
|
+
dependencies: [],
|
|
25
|
+
|
|
26
|
+
async started() {
|
|
27
|
+
this.logger.info(`🚶🚶🚶 ${this.name}-service has started...`)
|
|
28
|
+
|
|
29
|
+
this.serviceSettings = this.settings[this.name] ?? {}
|
|
30
|
+
|
|
31
|
+
this.meta = moleculerMeta({
|
|
32
|
+
actor: `${this.name}-service`,
|
|
33
|
+
federation: PRINCIPIA_FEDERATION_ID,
|
|
34
|
+
flowID: randomUUID(),
|
|
35
|
+
processID: randomUUID(),
|
|
36
|
+
role: MOLECULER_SERVICE_ROLE,
|
|
37
|
+
tokenKey: `${this.name}_${VERSION}`,
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
methods: {
|
|
42
|
+
/**
|
|
43
|
+
* @returns {string} The running package's version, read from `package.json`.
|
|
44
|
+
*/
|
|
45
|
+
packageVersion() {
|
|
46
|
+
return VERSION
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
actions: {
|
|
51
|
+
/**
|
|
52
|
+
* @returns {{version: string}} The running package's version.
|
|
53
|
+
*/
|
|
54
|
+
async packageVersion() {
|
|
55
|
+
return { version: VERSION }
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// NOTE: not yet re-exported from ./Mixins.js — see the comment there. Requires
|
|
2
|
+
// `@empyria/redis` and `@empyria/mongo`, which aren't published as standalone
|
|
3
|
+
// packages yet, so importing this file will currently fail to resolve.
|
|
4
|
+
import { OK } from '@empyria/classification'
|
|
5
|
+
|
|
6
|
+
import { RedisHelper } from '@empyria/redis'
|
|
7
|
+
import { connect, terminate } from '@empyria/mongo'
|
|
8
|
+
|
|
9
|
+
import { BaseMixin } from './Base.mixin.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extends {@link BaseMixin} with optional Redis/Mongo connection setup, driven by the
|
|
13
|
+
* service's `settings[serviceName.toLowerCase()].redis`/`.mongo` config.
|
|
14
|
+
*/
|
|
15
|
+
export const ConnectorMixin = {
|
|
16
|
+
name: 'ConnectorMixin',
|
|
17
|
+
|
|
18
|
+
mixins: [BaseMixin],
|
|
19
|
+
|
|
20
|
+
async started() {
|
|
21
|
+
this.logger.info(`🚶🚶🚶 ${this.name} has started...`)
|
|
22
|
+
|
|
23
|
+
if (this.settings[this.name.toLocaleLowerCase()]) {
|
|
24
|
+
const sets = this.settings[this.name.toLocaleLowerCase()]
|
|
25
|
+
if (sets.redis) {
|
|
26
|
+
this.redis = new RedisHelper(sets.redis, this.logger)
|
|
27
|
+
await this.redis.init()
|
|
28
|
+
|
|
29
|
+
this.logger.info(`${this.name} Redis setup done.`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (sets.mongo) {
|
|
33
|
+
const { mongoDB, mongoClient } = await connect(sets.mongo, this.logger)
|
|
34
|
+
this.mongoClient = mongoClient
|
|
35
|
+
this.mongoDB = mongoDB
|
|
36
|
+
|
|
37
|
+
this.logger.info(`${this.name} Mongo setup done.`)
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
this.logger.info(`😱😱😱 ${this.name} has no dedicated configuration...`)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async stopped() {
|
|
45
|
+
if (this.redis) await this.redis.terminate()
|
|
46
|
+
if (this.mongoClient) await terminate(this.mongoClient)
|
|
47
|
+
|
|
48
|
+
return OK
|
|
49
|
+
},
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@empyria/moleculer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Moleculer helpers for Principia",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Imre Fazekas <imre.fazekas@icloud.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/imrefazekas/empyria-guard-moleculer.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./Mixins.js",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./Mixins.js",
|
|
15
|
+
"./Connector.js": "./Connector.js",
|
|
16
|
+
"./mixins/*": "./mixins/*.js",
|
|
17
|
+
"./util/*": "./util/*.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"format": "bun oxfmt --check",
|
|
22
|
+
"format:fix": "bun oxfmt",
|
|
23
|
+
"lint": "bun oxlint",
|
|
24
|
+
"lint:fix": "bun oxlint --fix",
|
|
25
|
+
"test": "bun test --coverage",
|
|
26
|
+
"test:watch": "bun test --watch"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@empyria/classification": "0.1.0",
|
|
30
|
+
"@empyria/common": "0.1.0",
|
|
31
|
+
"moleculer": "0.15.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"oxfmt": "0.63.0",
|
|
35
|
+
"oxlint": "1.78.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.4.0",
|
|
39
|
+
"node": ">=26"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { ServiceBroker } from 'moleculer'
|
|
3
|
+
import { BaseMixin } from '../mixins/Base.mixin.js'
|
|
4
|
+
import { PRINCIPIA_FEDERATION_ID, MOLECULER_SERVICE_ROLE } from '@empyria/common'
|
|
5
|
+
|
|
6
|
+
let broker
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (broker?.started) await broker.stop()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('BaseMixin', () => {
|
|
13
|
+
test('stamps a Principia meta on start, and exposes the package version', async () => {
|
|
14
|
+
broker = new ServiceBroker({ logLevel: 'fatal', nodeID: 'base-mixin-test' })
|
|
15
|
+
broker.createService({ name: 'testsvc', mixins: [BaseMixin] })
|
|
16
|
+
await broker.start()
|
|
17
|
+
|
|
18
|
+
const svc = broker.getLocalService('testsvc')
|
|
19
|
+
expect(svc.meta.user.actor).toBe('testsvc-service')
|
|
20
|
+
expect(svc.meta.user.federation).toBe(PRINCIPIA_FEDERATION_ID)
|
|
21
|
+
expect(svc.meta.user.role).toBe(MOLECULER_SERVICE_ROLE)
|
|
22
|
+
expect(typeof svc.meta.user.flowID).toBe('string')
|
|
23
|
+
expect(typeof svc.meta.user.processID).toBe('string')
|
|
24
|
+
|
|
25
|
+
expect(typeof svc.packageVersion()).toBe('string')
|
|
26
|
+
expect(await broker.call('testsvc.packageVersion')).toEqual({
|
|
27
|
+
version: svc.packageVersion(),
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { connect } from '../Connector.js'
|
|
3
|
+
|
|
4
|
+
describe('connect', () => {
|
|
5
|
+
test('starts a broker and exposes call/stop', async () => {
|
|
6
|
+
const conn = await connect({ nodeID: 'connector-test-1', logLevel: 'fatal' })
|
|
7
|
+
try {
|
|
8
|
+
expect(conn.broker.started).toBe(true)
|
|
9
|
+
expect(typeof conn.call).toBe('function')
|
|
10
|
+
expect(typeof conn.stop).toBe('function')
|
|
11
|
+
|
|
12
|
+
conn.broker.createService({
|
|
13
|
+
name: 'echo',
|
|
14
|
+
actions: { ping: () => 'pong' },
|
|
15
|
+
})
|
|
16
|
+
await conn.broker.waitForServices('echo')
|
|
17
|
+
expect(await conn.call('echo.ping')).toBe('pong')
|
|
18
|
+
} finally {
|
|
19
|
+
await conn.stop()
|
|
20
|
+
}
|
|
21
|
+
expect(conn.broker.started).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('rejects when a required service never becomes available', async () => {
|
|
25
|
+
await expect(
|
|
26
|
+
connect({
|
|
27
|
+
nodeID: 'connector-test-2',
|
|
28
|
+
logLevel: 'fatal',
|
|
29
|
+
servicesRequired: ['nonexistent.service'],
|
|
30
|
+
waitTimeout: 200,
|
|
31
|
+
interval: 50,
|
|
32
|
+
}),
|
|
33
|
+
).rejects.toThrow()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
getParams,
|
|
4
|
+
getUser,
|
|
5
|
+
validateContext,
|
|
6
|
+
actionStamped,
|
|
7
|
+
timeStamped,
|
|
8
|
+
checkDefined,
|
|
9
|
+
} from '../util/Context.js'
|
|
10
|
+
import { PrincipiaError } from '@empyria/common'
|
|
11
|
+
|
|
12
|
+
describe('getParams', () => {
|
|
13
|
+
test('unwraps a body.packet envelope', () => {
|
|
14
|
+
expect(getParams({ params: { body: { packet: { a: 1 } } } })).toEqual({ a: 1 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('falls back to body when there is no packet', () => {
|
|
18
|
+
expect(getParams({ params: { body: { a: 1 } } })).toEqual({ a: 1 })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('falls back to raw params when there is no body', () => {
|
|
22
|
+
expect(getParams({ params: { a: 1 } })).toEqual({ a: 1 })
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('getUser', () => {
|
|
27
|
+
test('resolves the caller via v1.Auth.resolveToken, forwarding the action service meta', async () => {
|
|
28
|
+
const calls = []
|
|
29
|
+
const ctx = {
|
|
30
|
+
meta: { tokenKey: 'tok' },
|
|
31
|
+
call: async (...args) => {
|
|
32
|
+
calls.push(args)
|
|
33
|
+
return { actor: 'resolved' }
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
const action = { service: { meta: { tokenKey: 'service-key' } } }
|
|
37
|
+
|
|
38
|
+
const user = await getUser(ctx, action)
|
|
39
|
+
expect(user).toEqual({ actor: 'resolved' })
|
|
40
|
+
expect(calls).toEqual([
|
|
41
|
+
['v1.Auth.resolveToken', { tokenKey: 'tok' }, { meta: { tokenKey: 'service-key' } }],
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('returns undefined when there is no tokenKey', () => {
|
|
46
|
+
expect(getUser({ meta: {} }, { service: {} })).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('validateContext', () => {
|
|
51
|
+
test('returns the user/tokenKey for a well-formed context', () => {
|
|
52
|
+
const ctx = { meta: { user: { actor: 'a', federation: 'f' }, tokenKey: 'tok' } }
|
|
53
|
+
expect(validateContext(ctx, { name: 'v1.Test.action' })).toEqual({
|
|
54
|
+
user: { actor: 'a', federation: 'f' },
|
|
55
|
+
tokenKey: 'tok',
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('skips validation for internal ($-prefixed) actions', () => {
|
|
60
|
+
expect(validateContext({ meta: {} }, { name: '$node.health' })).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('throws when the context has no authenticated user', () => {
|
|
64
|
+
const ctx = { meta: {} }
|
|
65
|
+
expect(() => validateContext(ctx, { name: 'v1.Test.action' })).toThrow(PrincipiaError)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('actionStamped', () => {
|
|
70
|
+
test('builds an actor/tracing stamp with the current time', () => {
|
|
71
|
+
const before = Date.now()
|
|
72
|
+
const stamp = actionStamped({
|
|
73
|
+
actor: 'a',
|
|
74
|
+
federation: 'f',
|
|
75
|
+
role: 'r',
|
|
76
|
+
flowID: 'flow',
|
|
77
|
+
processID: 'proc',
|
|
78
|
+
})
|
|
79
|
+
expect(stamp.actor).toBe('a')
|
|
80
|
+
expect(stamp.federation).toBe('f')
|
|
81
|
+
expect(stamp.role).toBe('r')
|
|
82
|
+
expect(stamp.flowID).toBe('flow')
|
|
83
|
+
expect(stamp.processID).toBe('proc')
|
|
84
|
+
expect(stamp.timestamp).toBeGreaterThanOrEqual(before)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('timeStamped', () => {
|
|
89
|
+
test('sets a timestamp field (not the misspelled "timestmap")', () => {
|
|
90
|
+
const before = Date.now()
|
|
91
|
+
const obj = timeStamped({})
|
|
92
|
+
expect(obj).toHaveProperty('timestamp')
|
|
93
|
+
expect(obj).not.toHaveProperty('timestmap')
|
|
94
|
+
expect(obj.timestamp).toBeGreaterThanOrEqual(before)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('checkDefined', () => {
|
|
99
|
+
test('keeps only the defined properties', () => {
|
|
100
|
+
expect(checkDefined({ a: 1, b: undefined, c: null, d: 2 })).toEqual({ a: 1, d: 2 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('throws when nothing is defined', () => {
|
|
104
|
+
expect(() => checkDefined({ a: undefined, b: null })).toThrow(PrincipiaError)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
hashify,
|
|
4
|
+
stampIt,
|
|
5
|
+
generateKeys,
|
|
6
|
+
generateSignature,
|
|
7
|
+
verifySignature,
|
|
8
|
+
hashPassword,
|
|
9
|
+
verifyPassword,
|
|
10
|
+
} from '../util/Crypto.js'
|
|
11
|
+
|
|
12
|
+
describe('hashify', () => {
|
|
13
|
+
test('produces a stable SHA3-256 hex digest', () => {
|
|
14
|
+
const digest = hashify({ a: 1 })
|
|
15
|
+
expect(digest).toMatch(/^[0-9a-f]{64}$/)
|
|
16
|
+
expect(hashify({ a: 1 })).toBe(digest)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('stampIt', () => {
|
|
21
|
+
test('falls back to now() when there is no timestamper or default', async () => {
|
|
22
|
+
const before = Date.now()
|
|
23
|
+
const result = await stampIt(null, null, null)
|
|
24
|
+
expect(result).toBeGreaterThanOrEqual(before)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('returns the default value when given and there is no timestamper', async () => {
|
|
28
|
+
expect(await stampIt(null, null, 'fallback')).toBe('fallback')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('uses the timestamper when given', async () => {
|
|
32
|
+
const timestamper = { trustedTimestamp: async (hash) => `stamped:${hash}` }
|
|
33
|
+
const result = await stampIt({ a: 1 }, timestamper, 'unused')
|
|
34
|
+
expect(result).toBe(`stamped:${hashify({ a: 1 })}`)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('generateKeys / generateSignature / verifySignature', () => {
|
|
39
|
+
test('round-trips: a signature verifies against its own key pair', () => {
|
|
40
|
+
const { privKeyHex, pubKeyHex } = generateKeys()
|
|
41
|
+
expect(privKeyHex).toMatch(/^[0-9a-f]{64}$/)
|
|
42
|
+
expect(pubKeyHex).toMatch(/^[0-9a-f]{64}$/)
|
|
43
|
+
|
|
44
|
+
const signature = generateSignature('hello world', privKeyHex)
|
|
45
|
+
expect(verifySignature('hello world', signature, pubKeyHex)).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('rejects a signature for a different message', () => {
|
|
49
|
+
const { privKeyHex, pubKeyHex } = generateKeys()
|
|
50
|
+
const signature = generateSignature('original', privKeyHex)
|
|
51
|
+
expect(verifySignature('tampered', signature, pubKeyHex)).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('rejects a signature verified against a different key pair', () => {
|
|
55
|
+
const a = generateKeys()
|
|
56
|
+
const b = generateKeys()
|
|
57
|
+
const signature = generateSignature('hello', a.privKeyHex)
|
|
58
|
+
expect(verifySignature('hello', signature, b.pubKeyHex)).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('hashPassword / verifyPassword', () => {
|
|
63
|
+
test('round-trips: a password verifies against its own hash', async () => {
|
|
64
|
+
const hash = await hashPassword('correct horse battery staple')
|
|
65
|
+
expect(hash).toContain('.')
|
|
66
|
+
expect(await verifyPassword('correct horse battery staple', hash)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('rejects the wrong password', async () => {
|
|
70
|
+
const hash = await hashPassword('correct horse battery staple')
|
|
71
|
+
expect(await verifyPassword('wrong password', hash)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { MetaGuard } from '../MetaGuard.middleware.js'
|
|
3
|
+
import { PrincipiaError } from '@empyria/common'
|
|
4
|
+
|
|
5
|
+
const action = (name) => ({ name, service: { meta: {} } })
|
|
6
|
+
const ctxWithUser = (federation = 'my-fed', extra = {}) => ({
|
|
7
|
+
meta: { user: { actor: 'a', federation }, tokenKey: 'tok' },
|
|
8
|
+
...extra,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('MetaGuard: localAction / remoteAction', () => {
|
|
12
|
+
test('calls through when the context is valid and nothing is whitelisted', async () => {
|
|
13
|
+
const guard = MetaGuard({ resolveUser: false })
|
|
14
|
+
const result = await guard.localAction(
|
|
15
|
+
async () => 'ok',
|
|
16
|
+
action('v1.Test.action'),
|
|
17
|
+
)(ctxWithUser())
|
|
18
|
+
expect(result).toBe('ok')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('throws when the context has no authenticated user', async () => {
|
|
22
|
+
const guard = MetaGuard({ resolveUser: false })
|
|
23
|
+
await expect(
|
|
24
|
+
guard.localAction(async () => 'ok', action('v1.Test.action'))({ meta: {} }),
|
|
25
|
+
).rejects.toThrow(PrincipiaError)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('rejects a cross-federation call to a whitelisted action', async () => {
|
|
29
|
+
// Regression test: `whitelistedActions` used to be a hardcoded empty array with no
|
|
30
|
+
// way to configure it, so this branch was permanently dead code.
|
|
31
|
+
const guard = MetaGuard({
|
|
32
|
+
resolveUser: false,
|
|
33
|
+
whitelistedActions: ['v1.Restricted.action'],
|
|
34
|
+
})
|
|
35
|
+
await expect(
|
|
36
|
+
guard.localAction(
|
|
37
|
+
async () => 'ok',
|
|
38
|
+
action('v1.Restricted.action'),
|
|
39
|
+
)(ctxWithUser('other-federation')),
|
|
40
|
+
).rejects.toThrow(PrincipiaError)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('allows a same-federation call to a whitelisted action', async () => {
|
|
44
|
+
const guard = MetaGuard({
|
|
45
|
+
resolveUser: false,
|
|
46
|
+
whitelistedActions: ['v1.Restricted.action'],
|
|
47
|
+
})
|
|
48
|
+
const ctx = ctxWithUser('563ac2a1-3fe3-4c5f-b20f-6f33e5cbd680')
|
|
49
|
+
const result = await guard.localAction(
|
|
50
|
+
async () => 'ok',
|
|
51
|
+
action('v1.Restricted.action'),
|
|
52
|
+
)(ctx)
|
|
53
|
+
expect(result).toBe('ok')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('resolves and attaches the caller when resolveUser is set', async () => {
|
|
57
|
+
// Regression test: this used to call getUser(ctx) with no `action`, throwing
|
|
58
|
+
// "undefined is not an object (evaluating 'action.service')".
|
|
59
|
+
const guard = MetaGuard({ resolveUser: true })
|
|
60
|
+
const resolved = { actor: 'resolved-user' }
|
|
61
|
+
const ctx = ctxWithUser('my-fed', { call: async () => resolved })
|
|
62
|
+
const result = await guard.localAction(
|
|
63
|
+
async (c) => c.meta.user,
|
|
64
|
+
action('v1.Some.action'),
|
|
65
|
+
)(ctx)
|
|
66
|
+
expect(result).toBe(resolved)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('does not try to resolve the caller for v1.Auth.resolveToken itself', async () => {
|
|
70
|
+
const guard = MetaGuard({ resolveUser: true })
|
|
71
|
+
const originalUser = { actor: 'a', federation: 'my-fed' }
|
|
72
|
+
const ctx = {
|
|
73
|
+
meta: { user: originalUser, tokenKey: 'tok' },
|
|
74
|
+
call: async () => {
|
|
75
|
+
throw new Error('should not be called')
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
const result = await guard.localAction(
|
|
79
|
+
async (c) => c.meta.user,
|
|
80
|
+
action('v1.Auth.resolveToken'),
|
|
81
|
+
)(ctx)
|
|
82
|
+
expect(result).toBe(originalUser)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('remoteAction applies the same guard logic as localAction', async () => {
|
|
86
|
+
const guard = MetaGuard({ resolveUser: false })
|
|
87
|
+
const result = await guard.remoteAction(
|
|
88
|
+
async () => 'ok',
|
|
89
|
+
action('v1.Test.action'),
|
|
90
|
+
)(ctxWithUser())
|
|
91
|
+
expect(result).toBe('ok')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('MetaGuard: localEvent', () => {
|
|
96
|
+
test('calls through for a valid context', async () => {
|
|
97
|
+
const guard = MetaGuard({ resolveUser: false })
|
|
98
|
+
const result = await guard.localEvent(async () => 'ok', action('some.event'))(ctxWithUser())
|
|
99
|
+
expect(result).toBe('ok')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('throws for an invalid context', async () => {
|
|
103
|
+
const guard = MetaGuard({ resolveUser: false })
|
|
104
|
+
await expect(
|
|
105
|
+
guard.localEvent(async () => 'ok', action('some.event'))({ meta: {} }),
|
|
106
|
+
).rejects.toThrow(PrincipiaError)
|
|
107
|
+
})
|
|
108
|
+
})
|
package/util/Context.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Time, cloneDefined } from '@empyria/classification'
|
|
2
|
+
import { BaseErrors } from '@empyria/common'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extracts an action's actual input, unwrapping gateway/transport envelopes.
|
|
6
|
+
* @param {Object} ctx - Moleculer call context.
|
|
7
|
+
* @returns {*} `ctx.params.body.packet` if present, else `ctx.params.body`, else `ctx.params`.
|
|
8
|
+
*/
|
|
9
|
+
export function getParams(ctx) {
|
|
10
|
+
if (ctx.params.body && ctx.params.body.packet) return ctx.params.body.packet
|
|
11
|
+
if (ctx.params.body) return ctx.params.body
|
|
12
|
+
return ctx.params
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the calling user for a token-bearing context via `v1.Auth.resolveToken`.
|
|
17
|
+
* @param {Object} ctx - Moleculer call context; must have `ctx.meta.tokenKey`.
|
|
18
|
+
* @param {Object} action - The guarded action; `action.service.meta` is forwarded as the
|
|
19
|
+
* resolve call's own `meta` (so it's itself authenticated as the calling service).
|
|
20
|
+
* @returns {Promise<Object>|undefined} The resolved user, or `undefined` if there's no token.
|
|
21
|
+
*/
|
|
22
|
+
export function getUser(ctx, action) {
|
|
23
|
+
const tokenKey = ctx?.meta?.tokenKey
|
|
24
|
+
if (!tokenKey) return
|
|
25
|
+
|
|
26
|
+
const meta = action?.service?.meta
|
|
27
|
+
return ctx.call('v1.Auth.resolveToken', { tokenKey }, { meta })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validates that a context carries a well-formed, authenticated user, unless the
|
|
32
|
+
* action/event is an internal Moleculer one (name starts with `$`).
|
|
33
|
+
* @param {Object} ctx - Moleculer call context.
|
|
34
|
+
* @param {Object} [action] - The action/event being guarded.
|
|
35
|
+
* @returns {{user: Object, tokenKey: string}|undefined} The context's user/tokenKey, or
|
|
36
|
+
* `undefined` for a `$`-prefixed internal action.
|
|
37
|
+
* @throws {PrincipiaError} `BaseErrors.ServiceVoilation` if `ctx.meta.user` is missing or incomplete.
|
|
38
|
+
*/
|
|
39
|
+
export function validateContext(ctx, action) {
|
|
40
|
+
const { user, tokenKey } = ctx.meta
|
|
41
|
+
|
|
42
|
+
if (action && action.name.startsWith('$')) return
|
|
43
|
+
|
|
44
|
+
if (!user || !user.actor || !user.federation) {
|
|
45
|
+
throw BaseErrors.ServiceVoilation({
|
|
46
|
+
service: `Microservice ${ctx.action?.name || action.name} communication`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { user, tokenKey }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a stamp of a user's identity/tracing fields plus the current time, suitable for
|
|
55
|
+
* attaching to an entity as "who/when this happened".
|
|
56
|
+
* @param {Object} user - A context user (see {@link validateContext}).
|
|
57
|
+
* @returns {{actor: string, federation: string, role: string, flowID: string, processID: string, timestamp: number}}
|
|
58
|
+
*/
|
|
59
|
+
export function actionStamped(user) {
|
|
60
|
+
return {
|
|
61
|
+
actor: user.actor,
|
|
62
|
+
federation: user.federation,
|
|
63
|
+
role: user.role,
|
|
64
|
+
flowID: user.flowID,
|
|
65
|
+
processID: user.processID,
|
|
66
|
+
timestamp: Date.now() - Time.shift,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Adds a `timestamp` field (current time) to an object. Mutates and returns `object`.
|
|
72
|
+
* @param {Object} object - Object to stamp.
|
|
73
|
+
* @returns {Object} The same `object`, for chaining.
|
|
74
|
+
*/
|
|
75
|
+
export function timeStamped(object) {
|
|
76
|
+
object.timestamp = Date.now() - Time.shift
|
|
77
|
+
return object
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a shallow copy of `object` containing only its defined properties, requiring
|
|
82
|
+
* at least one to be present.
|
|
83
|
+
* @param {Object} object - Object to filter.
|
|
84
|
+
* @returns {Object} The defined-only copy.
|
|
85
|
+
* @throws {PrincipiaError} `BaseErrors.MissingData` if no properties are defined.
|
|
86
|
+
*/
|
|
87
|
+
export function checkDefined(object) {
|
|
88
|
+
const res = cloneDefined(object)
|
|
89
|
+
|
|
90
|
+
if (Object.keys(res).length === 0) throw BaseErrors.MissingData({ type: 'attributes' })
|
|
91
|
+
|
|
92
|
+
return res
|
|
93
|
+
}
|
package/util/Crypto.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
scrypt,
|
|
4
|
+
randomBytes,
|
|
5
|
+
timingSafeEqual,
|
|
6
|
+
generateKeyPairSync,
|
|
7
|
+
createPrivateKey,
|
|
8
|
+
createPublicKey,
|
|
9
|
+
sign,
|
|
10
|
+
verify,
|
|
11
|
+
} from 'node:crypto'
|
|
12
|
+
import { promisify } from 'node:util'
|
|
13
|
+
|
|
14
|
+
import { now } from '@empyria/classification'
|
|
15
|
+
|
|
16
|
+
const scryptAsync = promisify(scrypt)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SHA3-256 hashes an arbitrary JSON-serializable value.
|
|
20
|
+
* @param {*} obj - Value to hash.
|
|
21
|
+
* @returns {string} Hex-encoded digest.
|
|
22
|
+
*/
|
|
23
|
+
export function hashify(obj) {
|
|
24
|
+
return createHash('sha3-256').update(JSON.stringify(obj), 'utf8').digest('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Timestamps a value, using a trusted timestamping service if one is given.
|
|
29
|
+
* @param {*} toHash - Value to hash before timestamping (only used if `timestamper` is given).
|
|
30
|
+
* @param {{trustedTimestamp: (hash: string) => Promise<*>}} [timestamper] - Trusted timestamping
|
|
31
|
+
* service; if given, its result is returned.
|
|
32
|
+
* @param {*} [defaultValue] - Fallback value when there's no `timestamper`; defaults to `now()`.
|
|
33
|
+
* @returns {Promise<*>} The trusted timestamp, or `defaultValue`/`now()`.
|
|
34
|
+
*/
|
|
35
|
+
export async function stampIt(toHash, timestamper, defaultValue) {
|
|
36
|
+
let dataHash = timestamper && hashify(toHash)
|
|
37
|
+
return timestamper ? await timestamper.trustedTimestamp(dataHash) : defaultValue || now()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generates a raw (32-byte) Ed25519 key pair.
|
|
42
|
+
* @returns {{privKeyHex: string, pubKeyHex: string}} Hex-encoded private/public keys.
|
|
43
|
+
*/
|
|
44
|
+
export function generateKeys() {
|
|
45
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
|
46
|
+
|
|
47
|
+
// Export as raw 32-byte keys
|
|
48
|
+
const pubKeyRaw = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32)
|
|
49
|
+
const privKeyRaw = privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
privKeyHex: privKeyRaw.toString('hex'),
|
|
53
|
+
pubKeyHex: pubKeyRaw.toString('hex'),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ed25519-signs a UTF-8 message with a raw hex-encoded private key (as produced by {@link generateKeys}).
|
|
59
|
+
* @param {string} messageText - Message to sign.
|
|
60
|
+
* @param {string} privateKeyHex - Raw 32-byte Ed25519 private key, hex-encoded.
|
|
61
|
+
* @returns {string} Hex-encoded signature.
|
|
62
|
+
*/
|
|
63
|
+
export function generateSignature(messageText, privateKeyHex) {
|
|
64
|
+
const message = Buffer.from(messageText, 'utf8')
|
|
65
|
+
const rawKey = Buffer.from(privateKeyHex, 'hex')
|
|
66
|
+
const prefix = Buffer.from('302e020100300506032b657004220420', 'hex')
|
|
67
|
+
const privateKey = createPrivateKey({
|
|
68
|
+
key: Buffer.concat([prefix, rawKey]),
|
|
69
|
+
format: 'der',
|
|
70
|
+
type: 'pkcs8',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const signature = sign(null, message, privateKey)
|
|
74
|
+
return signature.toString('hex')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Verifies an Ed25519 signature produced by {@link generateSignature}.
|
|
79
|
+
* @param {string} messageText - Original signed message.
|
|
80
|
+
* @param {string} signatureHex - Hex-encoded signature to verify.
|
|
81
|
+
* @param {string} publicKeyHex - Raw 32-byte Ed25519 public key, hex-encoded.
|
|
82
|
+
* @returns {boolean} True if the signature is valid for the message and public key.
|
|
83
|
+
*/
|
|
84
|
+
export function verifySignature(messageText, signatureHex, publicKeyHex) {
|
|
85
|
+
const message = Buffer.from(messageText, 'utf8')
|
|
86
|
+
const signature = Buffer.from(signatureHex, 'hex')
|
|
87
|
+
const rawKey = Buffer.from(publicKeyHex, 'hex')
|
|
88
|
+
const prefix = Buffer.from('302a300506032b6570032100', 'hex')
|
|
89
|
+
const publicKey = createPublicKey({
|
|
90
|
+
key: Buffer.concat([prefix, rawKey]),
|
|
91
|
+
format: 'der',
|
|
92
|
+
type: 'spki',
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return verify(null, message, publicKey, signature)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Hashes a password with scrypt and a random salt.
|
|
100
|
+
* @param {string} password - Plain-text password.
|
|
101
|
+
* @returns {Promise<string>} `"<hex hash>.<hex salt>"`.
|
|
102
|
+
*/
|
|
103
|
+
export async function hashPassword(password) {
|
|
104
|
+
const salt = randomBytes(16).toString('hex')
|
|
105
|
+
const buf = await scryptAsync(password, salt, 64)
|
|
106
|
+
return `${buf.toString('hex')}.${salt}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Verifies a password against a hash produced by {@link hashPassword}, using a
|
|
111
|
+
* constant-time comparison.
|
|
112
|
+
* @param {string} password - Plain-text password to check.
|
|
113
|
+
* @param {string} hash - `"<hex hash>.<hex salt>"`, as produced by {@link hashPassword}.
|
|
114
|
+
* @returns {Promise<boolean>} True if `password` matches the hash.
|
|
115
|
+
*/
|
|
116
|
+
export async function verifyPassword(password, hash) {
|
|
117
|
+
const [hashedPassword, salt] = hash.split('.')
|
|
118
|
+
const hashedPasswordBuf = Buffer.from(hashedPassword, 'hex')
|
|
119
|
+
const buf = await scryptAsync(password, salt, 64)
|
|
120
|
+
return timingSafeEqual(hashedPasswordBuf, buf)
|
|
121
|
+
}
|