@saga-bus/middleware-tenant 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.cjs +158 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +124 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Dean Foran
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @saga-bus/middleware-tenant
|
|
2
|
+
|
|
3
|
+
Multi-tenant isolation middleware for saga-bus.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @saga-bus/middleware-tenant
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createTenantMiddleware, getTenantId } from "@saga-bus/middleware-tenant";
|
|
15
|
+
import { createBus } from "@saga-bus/core";
|
|
16
|
+
|
|
17
|
+
const tenantMiddleware = createTenantMiddleware({
|
|
18
|
+
strategy: "header",
|
|
19
|
+
headerName: "x-tenant-id",
|
|
20
|
+
required: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const bus = createBus({
|
|
24
|
+
middleware: [tenantMiddleware],
|
|
25
|
+
sagas: [...],
|
|
26
|
+
transport,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Access tenant in handlers
|
|
30
|
+
const tenantId = getTenantId(); // from AsyncLocalStorage context
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Tenant Resolution Strategies
|
|
34
|
+
|
|
35
|
+
### Header Strategy (default)
|
|
36
|
+
Extracts tenant ID from message headers:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
createTenantMiddleware({
|
|
40
|
+
strategy: "header",
|
|
41
|
+
headerName: "x-tenant-id",
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Correlation Prefix Strategy
|
|
46
|
+
Extracts tenant ID from correlation ID prefix:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
createTenantMiddleware({
|
|
50
|
+
strategy: "correlation-prefix",
|
|
51
|
+
correlationSeparator: ":",
|
|
52
|
+
});
|
|
53
|
+
// correlationId "tenant123:order-456" → tenantId "tenant123"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Custom Strategy
|
|
57
|
+
Provide your own resolver function:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
createTenantMiddleware({
|
|
61
|
+
strategy: "custom",
|
|
62
|
+
resolver: (envelope) => envelope.payload?.tenantId,
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Tenant-Aware Publishing
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { createTenantPublisher } from "@saga-bus/middleware-tenant";
|
|
70
|
+
|
|
71
|
+
const publish = createTenantPublisher(bus, {
|
|
72
|
+
headerName: "x-tenant-id",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Automatically adds tenant header from context
|
|
76
|
+
await publish({ type: "OrderCreated", payload: { orderId: "123" } });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
| Option | Type | Default | Description |
|
|
82
|
+
|--------|------|---------|-------------|
|
|
83
|
+
| `strategy` | `string` | `"header"` | Resolution strategy |
|
|
84
|
+
| `headerName` | `string` | `"x-tenant-id"` | Header name for header strategy |
|
|
85
|
+
| `correlationSeparator` | `string` | `":"` | Separator for correlation prefix |
|
|
86
|
+
| `resolver` | `function` | - | Custom resolver function |
|
|
87
|
+
| `required` | `boolean` | `true` | Require tenant on all messages |
|
|
88
|
+
| `defaultTenantId` | `string` | - | Default when not required |
|
|
89
|
+
| `allowedTenants` | `string[]` | - | Allowlist of valid tenants |
|
|
90
|
+
| `prefixSagaId` | `boolean` | `true` | Prefix saga IDs with tenant |
|
|
91
|
+
|
|
92
|
+
## Context API
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import {
|
|
96
|
+
getTenantId,
|
|
97
|
+
getTenantInfo,
|
|
98
|
+
requireTenantId,
|
|
99
|
+
runWithTenant,
|
|
100
|
+
} from "@saga-bus/middleware-tenant";
|
|
101
|
+
|
|
102
|
+
// Get current tenant (undefined if not set)
|
|
103
|
+
const tenantId = getTenantId();
|
|
104
|
+
|
|
105
|
+
// Get full tenant info
|
|
106
|
+
const info = getTenantInfo(); // { tenantId, originalSagaId }
|
|
107
|
+
|
|
108
|
+
// Throw if no tenant
|
|
109
|
+
const tenantId = requireTenantId();
|
|
110
|
+
|
|
111
|
+
// Run code with explicit tenant context
|
|
112
|
+
await runWithTenant("tenant-123", async () => {
|
|
113
|
+
// getTenantId() returns "tenant-123" here
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
TenantNotAllowedError: () => TenantNotAllowedError,
|
|
24
|
+
TenantResolutionError: () => TenantResolutionError,
|
|
25
|
+
createTenantMiddleware: () => createTenantMiddleware,
|
|
26
|
+
createTenantPublisher: () => createTenantPublisher,
|
|
27
|
+
getTenantId: () => getTenantId,
|
|
28
|
+
getTenantInfo: () => getTenantInfo,
|
|
29
|
+
requireTenantId: () => requireTenantId,
|
|
30
|
+
runWithTenant: () => runWithTenant
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
|
|
34
|
+
// src/types.ts
|
|
35
|
+
var TenantResolutionError = class extends Error {
|
|
36
|
+
constructor(message, envelope) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.envelope = envelope;
|
|
39
|
+
this.name = "TenantResolutionError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var TenantNotAllowedError = class extends Error {
|
|
43
|
+
constructor(tenantId, envelope) {
|
|
44
|
+
super(`Tenant "${tenantId}" is not allowed`);
|
|
45
|
+
this.tenantId = tenantId;
|
|
46
|
+
this.envelope = envelope;
|
|
47
|
+
this.name = "TenantNotAllowedError";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/TenantContext.ts
|
|
52
|
+
var import_node_async_hooks = require("async_hooks");
|
|
53
|
+
var tenantStorage = new import_node_async_hooks.AsyncLocalStorage();
|
|
54
|
+
function runWithTenant(tenantInfo, fn) {
|
|
55
|
+
return tenantStorage.run(tenantInfo, fn);
|
|
56
|
+
}
|
|
57
|
+
function getTenantId() {
|
|
58
|
+
return tenantStorage.getStore()?.tenantId;
|
|
59
|
+
}
|
|
60
|
+
function getTenantInfo() {
|
|
61
|
+
return tenantStorage.getStore();
|
|
62
|
+
}
|
|
63
|
+
function requireTenantId() {
|
|
64
|
+
const tenantId = getTenantId();
|
|
65
|
+
if (!tenantId) {
|
|
66
|
+
throw new Error("No tenant context available");
|
|
67
|
+
}
|
|
68
|
+
return tenantId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/TenantMiddleware.ts
|
|
72
|
+
function createTenantMiddleware(options = {}) {
|
|
73
|
+
const strategy = options.strategy ?? "header";
|
|
74
|
+
const headerName = options.headerName ?? "x-tenant-id";
|
|
75
|
+
const correlationSeparator = options.correlationSeparator ?? ":";
|
|
76
|
+
const required = options.required ?? true;
|
|
77
|
+
const defaultTenantId = options.defaultTenantId;
|
|
78
|
+
const allowedTenants = options.allowedTenants ? new Set(options.allowedTenants) : void 0;
|
|
79
|
+
const prefixSagaId = options.prefixSagaId ?? true;
|
|
80
|
+
const resolver = options.resolver ?? createResolver(strategy, {
|
|
81
|
+
headerName,
|
|
82
|
+
correlationSeparator
|
|
83
|
+
});
|
|
84
|
+
return async (ctx, next) => {
|
|
85
|
+
const { envelope } = ctx;
|
|
86
|
+
let tenantId = resolver(envelope);
|
|
87
|
+
if (!tenantId) {
|
|
88
|
+
if (required) {
|
|
89
|
+
throw new TenantResolutionError(
|
|
90
|
+
`Could not resolve tenant ID from message using strategy "${strategy}"`,
|
|
91
|
+
envelope
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
tenantId = defaultTenantId;
|
|
95
|
+
}
|
|
96
|
+
if (!tenantId) {
|
|
97
|
+
await next();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (allowedTenants && !allowedTenants.has(tenantId)) {
|
|
101
|
+
throw new TenantNotAllowedError(tenantId, envelope);
|
|
102
|
+
}
|
|
103
|
+
let originalSagaId;
|
|
104
|
+
if (prefixSagaId && ctx.sagaId) {
|
|
105
|
+
originalSagaId = ctx.sagaId;
|
|
106
|
+
ctx.sagaId = `${tenantId}:${ctx.sagaId}`;
|
|
107
|
+
}
|
|
108
|
+
await runWithTenant({ tenantId, originalSagaId }, async () => {
|
|
109
|
+
await next();
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function createTenantPublisher(options = {}) {
|
|
114
|
+
const headerName = options.headerName ?? "x-tenant-id";
|
|
115
|
+
return (envelope, tenantId) => {
|
|
116
|
+
return {
|
|
117
|
+
...envelope,
|
|
118
|
+
headers: {
|
|
119
|
+
...envelope.headers,
|
|
120
|
+
[headerName]: tenantId
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createResolver(strategy, options) {
|
|
126
|
+
switch (strategy) {
|
|
127
|
+
case "header":
|
|
128
|
+
return (envelope) => {
|
|
129
|
+
return envelope.headers[options.headerName];
|
|
130
|
+
};
|
|
131
|
+
case "correlation-prefix":
|
|
132
|
+
return (envelope) => {
|
|
133
|
+
const partitionKey = envelope.partitionKey;
|
|
134
|
+
if (!partitionKey) {
|
|
135
|
+
return void 0;
|
|
136
|
+
}
|
|
137
|
+
const separatorIndex = partitionKey.indexOf(options.correlationSeparator);
|
|
138
|
+
if (separatorIndex === -1) {
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
return partitionKey.slice(0, separatorIndex);
|
|
142
|
+
};
|
|
143
|
+
case "custom":
|
|
144
|
+
throw new Error("Custom strategy requires a resolver function");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
148
|
+
0 && (module.exports = {
|
|
149
|
+
TenantNotAllowedError,
|
|
150
|
+
TenantResolutionError,
|
|
151
|
+
createTenantMiddleware,
|
|
152
|
+
createTenantPublisher,
|
|
153
|
+
getTenantId,
|
|
154
|
+
getTenantInfo,
|
|
155
|
+
requireTenantId,
|
|
156
|
+
runWithTenant
|
|
157
|
+
});
|
|
158
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/types.ts","../src/TenantContext.ts","../src/TenantMiddleware.ts"],"sourcesContent":["export {\n createTenantMiddleware,\n createTenantPublisher,\n} from \"./TenantMiddleware.js\";\nexport {\n getTenantId,\n getTenantInfo,\n requireTenantId,\n runWithTenant,\n} from \"./TenantContext.js\";\nexport type {\n TenantMiddlewareOptions,\n TenantResolver,\n TenantInfo,\n} from \"./types.js\";\nexport { TenantResolutionError, TenantNotAllowedError } from \"./types.js\";\n","import type { MessageEnvelope } from \"@saga-bus/core\";\n\n/**\n * Tenant middleware configuration options.\n */\nexport interface TenantMiddlewareOptions {\n /**\n * Strategy for resolving tenant ID from messages.\n * @default \"header\"\n */\n strategy?: \"header\" | \"correlation-prefix\" | \"custom\";\n\n /**\n * Custom tenant resolver function.\n * Required when strategy is \"custom\".\n */\n resolver?: TenantResolver;\n\n /**\n * Header name for tenant ID when using \"header\" strategy.\n * @default \"x-tenant-id\"\n */\n headerName?: string;\n\n /**\n * Separator for correlation prefix strategy.\n * @default \":\"\n */\n correlationSeparator?: string;\n\n /**\n * Whether to require tenant ID on all messages.\n * @default true\n */\n required?: boolean;\n\n /**\n * Default tenant ID when not required and not found.\n */\n defaultTenantId?: string;\n\n /**\n * Allowlist of valid tenant IDs.\n * If provided, messages from unknown tenants are rejected.\n */\n allowedTenants?: string[];\n\n /**\n * Whether to prefix saga IDs with tenant ID for isolation.\n * @default true\n */\n prefixSagaId?: boolean;\n}\n\n/**\n * Function to resolve tenant ID from a message.\n */\nexport type TenantResolver = (envelope: MessageEnvelope) => string | undefined;\n\n/**\n * Tenant context for the current request.\n */\nexport interface TenantInfo {\n /**\n * Resolved tenant ID.\n */\n tenantId: string;\n\n /**\n * Original saga ID before tenant prefix.\n */\n originalSagaId?: string;\n}\n\n/**\n * Error thrown when tenant resolution fails.\n */\nexport class TenantResolutionError extends Error {\n constructor(\n message: string,\n public readonly envelope: MessageEnvelope\n ) {\n super(message);\n this.name = \"TenantResolutionError\";\n }\n}\n\n/**\n * Error thrown when tenant is not in allowlist.\n */\nexport class TenantNotAllowedError extends Error {\n constructor(\n public readonly tenantId: string,\n public readonly envelope: MessageEnvelope\n ) {\n super(`Tenant \"${tenantId}\" is not allowed`);\n this.name = \"TenantNotAllowedError\";\n }\n}\n","import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { TenantInfo } from \"./types.js\";\n\nconst tenantStorage = new AsyncLocalStorage<TenantInfo>();\n\n/**\n * Run a function within a tenant context.\n */\nexport function runWithTenant<T>(\n tenantInfo: TenantInfo,\n fn: () => T | Promise<T>\n): T | Promise<T> {\n return tenantStorage.run(tenantInfo, fn);\n}\n\n/**\n * Get the current tenant ID.\n * Returns undefined if not in a tenant context.\n */\nexport function getTenantId(): string | undefined {\n return tenantStorage.getStore()?.tenantId;\n}\n\n/**\n * Get the full tenant info for the current context.\n */\nexport function getTenantInfo(): TenantInfo | undefined {\n return tenantStorage.getStore();\n}\n\n/**\n * Get the current tenant ID or throw if not available.\n */\nexport function requireTenantId(): string {\n const tenantId = getTenantId();\n if (!tenantId) {\n throw new Error(\"No tenant context available\");\n }\n return tenantId;\n}\n","import type {\n SagaMiddleware,\n SagaPipelineContext,\n MessageEnvelope,\n} from \"@saga-bus/core\";\nimport type { TenantMiddlewareOptions, TenantResolver } from \"./types.js\";\nimport { TenantResolutionError, TenantNotAllowedError } from \"./types.js\";\nimport { runWithTenant } from \"./TenantContext.js\";\n\n/**\n * Create multi-tenant middleware for saga-bus.\n *\n * Resolves tenant ID from messages and provides tenant context\n * via AsyncLocalStorage for use within handlers.\n *\n * @example\n * ```typescript\n * const middleware = createTenantMiddleware({\n * strategy: \"header\",\n * headerName: \"x-tenant-id\",\n * allowedTenants: [\"tenant1\", \"tenant2\"],\n * });\n *\n * const bus = createMessageBus({\n * middleware: [middleware],\n * });\n * ```\n */\nexport function createTenantMiddleware(\n options: TenantMiddlewareOptions = {}\n): SagaMiddleware {\n const strategy = options.strategy ?? \"header\";\n const headerName = options.headerName ?? \"x-tenant-id\";\n const correlationSeparator = options.correlationSeparator ?? \":\";\n const required = options.required ?? true;\n const defaultTenantId = options.defaultTenantId;\n const allowedTenants = options.allowedTenants\n ? new Set(options.allowedTenants)\n : undefined;\n const prefixSagaId = options.prefixSagaId ?? true;\n\n const resolver =\n options.resolver ??\n createResolver(strategy, {\n headerName,\n correlationSeparator,\n });\n\n return async (ctx: SagaPipelineContext, next: () => Promise<void>) => {\n const { envelope } = ctx;\n\n // Resolve tenant ID\n let tenantId = resolver(envelope);\n\n if (!tenantId) {\n if (required) {\n throw new TenantResolutionError(\n `Could not resolve tenant ID from message using strategy \"${strategy}\"`,\n envelope\n );\n }\n tenantId = defaultTenantId;\n }\n\n if (!tenantId) {\n // No tenant and not required - proceed without tenant context\n await next();\n return;\n }\n\n // Validate against allowlist\n if (allowedTenants && !allowedTenants.has(tenantId)) {\n throw new TenantNotAllowedError(tenantId, envelope);\n }\n\n // Modify saga ID if needed\n let originalSagaId: string | undefined;\n if (prefixSagaId && ctx.sagaId) {\n originalSagaId = ctx.sagaId;\n ctx.sagaId = `${tenantId}:${ctx.sagaId}`;\n }\n\n // Run handler within tenant context\n await runWithTenant({ tenantId, originalSagaId }, async () => {\n await next();\n });\n };\n}\n\n/**\n * Create a publish interceptor that adds tenant ID to outbound messages.\n *\n * @example\n * ```typescript\n * const publisher = createTenantPublisher();\n * const envelope = publisher(originalEnvelope, \"tenant1\");\n * ```\n */\nexport function createTenantPublisher(\n options: Pick<TenantMiddlewareOptions, \"headerName\"> = {}\n): (envelope: MessageEnvelope, tenantId: string) => MessageEnvelope {\n const headerName = options.headerName ?? \"x-tenant-id\";\n\n return (envelope: MessageEnvelope, tenantId: string): MessageEnvelope => {\n return {\n ...envelope,\n headers: {\n ...envelope.headers,\n [headerName]: tenantId,\n },\n };\n };\n}\n\nfunction createResolver(\n strategy: \"header\" | \"correlation-prefix\" | \"custom\",\n options: {\n headerName: string;\n correlationSeparator: string;\n }\n): TenantResolver {\n switch (strategy) {\n case \"header\":\n return (envelope: MessageEnvelope) => {\n return envelope.headers[options.headerName];\n };\n\n case \"correlation-prefix\":\n return (envelope: MessageEnvelope) => {\n const partitionKey = envelope.partitionKey;\n if (!partitionKey) {\n return undefined;\n }\n const separatorIndex = partitionKey.indexOf(options.correlationSeparator);\n if (separatorIndex === -1) {\n return undefined;\n }\n return partitionKey.slice(0, separatorIndex);\n };\n\n case \"custom\":\n throw new Error(\"Custom strategy requires a resolver function\");\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AC6EO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC/C,YACE,SACgB,UAChB;AACA,UAAM,OAAO;AAFG;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAKO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC/C,YACkB,UACA,UAChB;AACA,UAAM,WAAW,QAAQ,kBAAkB;AAH3B;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;;;AClGA,8BAAkC;AAGlC,IAAM,gBAAgB,IAAI,0CAA8B;AAKjD,SAAS,cACd,YACA,IACgB;AAChB,SAAO,cAAc,IAAI,YAAY,EAAE;AACzC;AAMO,SAAS,cAAkC;AAChD,SAAO,cAAc,SAAS,GAAG;AACnC;AAKO,SAAS,gBAAwC;AACtD,SAAO,cAAc,SAAS;AAChC;AAKO,SAAS,kBAA0B;AACxC,QAAM,WAAW,YAAY;AAC7B,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,6BAA6B;AAAA,EAC/C;AACA,SAAO;AACT;;;ACXO,SAAS,uBACd,UAAmC,CAAC,GACpB;AAChB,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,aAAa,QAAQ,cAAc;AACzC,QAAM,uBAAuB,QAAQ,wBAAwB;AAC7D,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,kBAAkB,QAAQ;AAChC,QAAM,iBAAiB,QAAQ,iBAC3B,IAAI,IAAI,QAAQ,cAAc,IAC9B;AACJ,QAAM,eAAe,QAAQ,gBAAgB;AAE7C,QAAM,WACJ,QAAQ,YACR,eAAe,UAAU;AAAA,IACvB;AAAA,IACA;AAAA,EACF,CAAC;AAEH,SAAO,OAAO,KAA0B,SAA8B;AACpE,UAAM,EAAE,SAAS,IAAI;AAGrB,QAAI,WAAW,SAAS,QAAQ;AAEhC,QAAI,CAAC,UAAU;AACb,UAAI,UAAU;AACZ,cAAM,IAAI;AAAA,UACR,4DAA4D,QAAQ;AAAA,UACpE;AAAA,QACF;AAAA,MACF;AACA,iBAAW;AAAA,IACb;AAEA,QAAI,CAAC,UAAU;AAEb,YAAM,KAAK;AACX;AAAA,IACF;AAGA,QAAI,kBAAkB,CAAC,eAAe,IAAI,QAAQ,GAAG;AACnD,YAAM,IAAI,sBAAsB,UAAU,QAAQ;AAAA,IACpD;AAGA,QAAI;AACJ,QAAI,gBAAgB,IAAI,QAAQ;AAC9B,uBAAiB,IAAI;AACrB,UAAI,SAAS,GAAG,QAAQ,IAAI,IAAI,MAAM;AAAA,IACxC;AAGA,UAAM,cAAc,EAAE,UAAU,eAAe,GAAG,YAAY;AAC5D,YAAM,KAAK;AAAA,IACb,CAAC;AAAA,EACH;AACF;AAWO,SAAS,sBACd,UAAuD,CAAC,GACU;AAClE,QAAM,aAAa,QAAQ,cAAc;AAEzC,SAAO,CAAC,UAA2B,aAAsC;AACvE,WAAO;AAAA,MACL,GAAG;AAAA,MACH,SAAS;AAAA,QACP,GAAG,SAAS;AAAA,QACZ,CAAC,UAAU,GAAG;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,eACP,UACA,SAIgB;AAChB,UAAQ,UAAU;AAAA,IAChB,KAAK;AACH,aAAO,CAAC,aAA8B;AACpC,eAAO,SAAS,QAAQ,QAAQ,UAAU;AAAA,MAC5C;AAAA,IAEF,KAAK;AACH,aAAO,CAAC,aAA8B;AACpC,cAAM,eAAe,SAAS;AAC9B,YAAI,CAAC,cAAc;AACjB,iBAAO;AAAA,QACT;AACA,cAAM,iBAAiB,aAAa,QAAQ,QAAQ,oBAAoB;AACxE,YAAI,mBAAmB,IAAI;AACzB,iBAAO;AAAA,QACT;AACA,eAAO,aAAa,MAAM,GAAG,cAAc;AAAA,MAC7C;AAAA,IAEF,KAAK;AACH,YAAM,IAAI,MAAM,8CAA8C;AAAA,EAClE;AACF;","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { MessageEnvelope, SagaMiddleware } from '@saga-bus/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tenant middleware configuration options.
|
|
5
|
+
*/
|
|
6
|
+
interface TenantMiddlewareOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Strategy for resolving tenant ID from messages.
|
|
9
|
+
* @default "header"
|
|
10
|
+
*/
|
|
11
|
+
strategy?: "header" | "correlation-prefix" | "custom";
|
|
12
|
+
/**
|
|
13
|
+
* Custom tenant resolver function.
|
|
14
|
+
* Required when strategy is "custom".
|
|
15
|
+
*/
|
|
16
|
+
resolver?: TenantResolver;
|
|
17
|
+
/**
|
|
18
|
+
* Header name for tenant ID when using "header" strategy.
|
|
19
|
+
* @default "x-tenant-id"
|
|
20
|
+
*/
|
|
21
|
+
headerName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Separator for correlation prefix strategy.
|
|
24
|
+
* @default ":"
|
|
25
|
+
*/
|
|
26
|
+
correlationSeparator?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Whether to require tenant ID on all messages.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
required?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Default tenant ID when not required and not found.
|
|
34
|
+
*/
|
|
35
|
+
defaultTenantId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Allowlist of valid tenant IDs.
|
|
38
|
+
* If provided, messages from unknown tenants are rejected.
|
|
39
|
+
*/
|
|
40
|
+
allowedTenants?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Whether to prefix saga IDs with tenant ID for isolation.
|
|
43
|
+
* @default true
|
|
44
|
+
*/
|
|
45
|
+
prefixSagaId?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Function to resolve tenant ID from a message.
|
|
49
|
+
*/
|
|
50
|
+
type TenantResolver = (envelope: MessageEnvelope) => string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Tenant context for the current request.
|
|
53
|
+
*/
|
|
54
|
+
interface TenantInfo {
|
|
55
|
+
/**
|
|
56
|
+
* Resolved tenant ID.
|
|
57
|
+
*/
|
|
58
|
+
tenantId: string;
|
|
59
|
+
/**
|
|
60
|
+
* Original saga ID before tenant prefix.
|
|
61
|
+
*/
|
|
62
|
+
originalSagaId?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Error thrown when tenant resolution fails.
|
|
66
|
+
*/
|
|
67
|
+
declare class TenantResolutionError extends Error {
|
|
68
|
+
readonly envelope: MessageEnvelope;
|
|
69
|
+
constructor(message: string, envelope: MessageEnvelope);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Error thrown when tenant is not in allowlist.
|
|
73
|
+
*/
|
|
74
|
+
declare class TenantNotAllowedError extends Error {
|
|
75
|
+
readonly tenantId: string;
|
|
76
|
+
readonly envelope: MessageEnvelope;
|
|
77
|
+
constructor(tenantId: string, envelope: MessageEnvelope);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create multi-tenant middleware for saga-bus.
|
|
82
|
+
*
|
|
83
|
+
* Resolves tenant ID from messages and provides tenant context
|
|
84
|
+
* via AsyncLocalStorage for use within handlers.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* const middleware = createTenantMiddleware({
|
|
89
|
+
* strategy: "header",
|
|
90
|
+
* headerName: "x-tenant-id",
|
|
91
|
+
* allowedTenants: ["tenant1", "tenant2"],
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* const bus = createMessageBus({
|
|
95
|
+
* middleware: [middleware],
|
|
96
|
+
* });
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
declare function createTenantMiddleware(options?: TenantMiddlewareOptions): SagaMiddleware;
|
|
100
|
+
/**
|
|
101
|
+
* Create a publish interceptor that adds tenant ID to outbound messages.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* const publisher = createTenantPublisher();
|
|
106
|
+
* const envelope = publisher(originalEnvelope, "tenant1");
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare function createTenantPublisher(options?: Pick<TenantMiddlewareOptions, "headerName">): (envelope: MessageEnvelope, tenantId: string) => MessageEnvelope;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run a function within a tenant context.
|
|
113
|
+
*/
|
|
114
|
+
declare function runWithTenant<T>(tenantInfo: TenantInfo, fn: () => T | Promise<T>): T | Promise<T>;
|
|
115
|
+
/**
|
|
116
|
+
* Get the current tenant ID.
|
|
117
|
+
* Returns undefined if not in a tenant context.
|
|
118
|
+
*/
|
|
119
|
+
declare function getTenantId(): string | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* Get the full tenant info for the current context.
|
|
122
|
+
*/
|
|
123
|
+
declare function getTenantInfo(): TenantInfo | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Get the current tenant ID or throw if not available.
|
|
126
|
+
*/
|
|
127
|
+
declare function requireTenantId(): string;
|
|
128
|
+
|
|
129
|
+
export { type TenantInfo, type TenantMiddlewareOptions, TenantNotAllowedError, TenantResolutionError, type TenantResolver, createTenantMiddleware, createTenantPublisher, getTenantId, getTenantInfo, requireTenantId, runWithTenant };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { MessageEnvelope, SagaMiddleware } from '@saga-bus/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tenant middleware configuration options.
|
|
5
|
+
*/
|
|
6
|
+
interface TenantMiddlewareOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Strategy for resolving tenant ID from messages.
|
|
9
|
+
* @default "header"
|
|
10
|
+
*/
|
|
11
|
+
strategy?: "header" | "correlation-prefix" | "custom";
|
|
12
|
+
/**
|
|
13
|
+
* Custom tenant resolver function.
|
|
14
|
+
* Required when strategy is "custom".
|
|
15
|
+
*/
|
|
16
|
+
resolver?: TenantResolver;
|
|
17
|
+
/**
|
|
18
|
+
* Header name for tenant ID when using "header" strategy.
|
|
19
|
+
* @default "x-tenant-id"
|
|
20
|
+
*/
|
|
21
|
+
headerName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Separator for correlation prefix strategy.
|
|
24
|
+
* @default ":"
|
|
25
|
+
*/
|
|
26
|
+
correlationSeparator?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Whether to require tenant ID on all messages.
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
required?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Default tenant ID when not required and not found.
|
|
34
|
+
*/
|
|
35
|
+
defaultTenantId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Allowlist of valid tenant IDs.
|
|
38
|
+
* If provided, messages from unknown tenants are rejected.
|
|
39
|
+
*/
|
|
40
|
+
allowedTenants?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Whether to prefix saga IDs with tenant ID for isolation.
|
|
43
|
+
* @default true
|
|
44
|
+
*/
|
|
45
|
+
prefixSagaId?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Function to resolve tenant ID from a message.
|
|
49
|
+
*/
|
|
50
|
+
type TenantResolver = (envelope: MessageEnvelope) => string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Tenant context for the current request.
|
|
53
|
+
*/
|
|
54
|
+
interface TenantInfo {
|
|
55
|
+
/**
|
|
56
|
+
* Resolved tenant ID.
|
|
57
|
+
*/
|
|
58
|
+
tenantId: string;
|
|
59
|
+
/**
|
|
60
|
+
* Original saga ID before tenant prefix.
|
|
61
|
+
*/
|
|
62
|
+
originalSagaId?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Error thrown when tenant resolution fails.
|
|
66
|
+
*/
|
|
67
|
+
declare class TenantResolutionError extends Error {
|
|
68
|
+
readonly envelope: MessageEnvelope;
|
|
69
|
+
constructor(message: string, envelope: MessageEnvelope);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Error thrown when tenant is not in allowlist.
|
|
73
|
+
*/
|
|
74
|
+
declare class TenantNotAllowedError extends Error {
|
|
75
|
+
readonly tenantId: string;
|
|
76
|
+
readonly envelope: MessageEnvelope;
|
|
77
|
+
constructor(tenantId: string, envelope: MessageEnvelope);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create multi-tenant middleware for saga-bus.
|
|
82
|
+
*
|
|
83
|
+
* Resolves tenant ID from messages and provides tenant context
|
|
84
|
+
* via AsyncLocalStorage for use within handlers.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* const middleware = createTenantMiddleware({
|
|
89
|
+
* strategy: "header",
|
|
90
|
+
* headerName: "x-tenant-id",
|
|
91
|
+
* allowedTenants: ["tenant1", "tenant2"],
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* const bus = createMessageBus({
|
|
95
|
+
* middleware: [middleware],
|
|
96
|
+
* });
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
declare function createTenantMiddleware(options?: TenantMiddlewareOptions): SagaMiddleware;
|
|
100
|
+
/**
|
|
101
|
+
* Create a publish interceptor that adds tenant ID to outbound messages.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* const publisher = createTenantPublisher();
|
|
106
|
+
* const envelope = publisher(originalEnvelope, "tenant1");
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare function createTenantPublisher(options?: Pick<TenantMiddlewareOptions, "headerName">): (envelope: MessageEnvelope, tenantId: string) => MessageEnvelope;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run a function within a tenant context.
|
|
113
|
+
*/
|
|
114
|
+
declare function runWithTenant<T>(tenantInfo: TenantInfo, fn: () => T | Promise<T>): T | Promise<T>;
|
|
115
|
+
/**
|
|
116
|
+
* Get the current tenant ID.
|
|
117
|
+
* Returns undefined if not in a tenant context.
|
|
118
|
+
*/
|
|
119
|
+
declare function getTenantId(): string | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* Get the full tenant info for the current context.
|
|
122
|
+
*/
|
|
123
|
+
declare function getTenantInfo(): TenantInfo | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Get the current tenant ID or throw if not available.
|
|
126
|
+
*/
|
|
127
|
+
declare function requireTenantId(): string;
|
|
128
|
+
|
|
129
|
+
export { type TenantInfo, type TenantMiddlewareOptions, TenantNotAllowedError, TenantResolutionError, type TenantResolver, createTenantMiddleware, createTenantPublisher, getTenantId, getTenantInfo, requireTenantId, runWithTenant };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var TenantResolutionError = class extends Error {
|
|
3
|
+
constructor(message, envelope) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.envelope = envelope;
|
|
6
|
+
this.name = "TenantResolutionError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var TenantNotAllowedError = class extends Error {
|
|
10
|
+
constructor(tenantId, envelope) {
|
|
11
|
+
super(`Tenant "${tenantId}" is not allowed`);
|
|
12
|
+
this.tenantId = tenantId;
|
|
13
|
+
this.envelope = envelope;
|
|
14
|
+
this.name = "TenantNotAllowedError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/TenantContext.ts
|
|
19
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
20
|
+
var tenantStorage = new AsyncLocalStorage();
|
|
21
|
+
function runWithTenant(tenantInfo, fn) {
|
|
22
|
+
return tenantStorage.run(tenantInfo, fn);
|
|
23
|
+
}
|
|
24
|
+
function getTenantId() {
|
|
25
|
+
return tenantStorage.getStore()?.tenantId;
|
|
26
|
+
}
|
|
27
|
+
function getTenantInfo() {
|
|
28
|
+
return tenantStorage.getStore();
|
|
29
|
+
}
|
|
30
|
+
function requireTenantId() {
|
|
31
|
+
const tenantId = getTenantId();
|
|
32
|
+
if (!tenantId) {
|
|
33
|
+
throw new Error("No tenant context available");
|
|
34
|
+
}
|
|
35
|
+
return tenantId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/TenantMiddleware.ts
|
|
39
|
+
function createTenantMiddleware(options = {}) {
|
|
40
|
+
const strategy = options.strategy ?? "header";
|
|
41
|
+
const headerName = options.headerName ?? "x-tenant-id";
|
|
42
|
+
const correlationSeparator = options.correlationSeparator ?? ":";
|
|
43
|
+
const required = options.required ?? true;
|
|
44
|
+
const defaultTenantId = options.defaultTenantId;
|
|
45
|
+
const allowedTenants = options.allowedTenants ? new Set(options.allowedTenants) : void 0;
|
|
46
|
+
const prefixSagaId = options.prefixSagaId ?? true;
|
|
47
|
+
const resolver = options.resolver ?? createResolver(strategy, {
|
|
48
|
+
headerName,
|
|
49
|
+
correlationSeparator
|
|
50
|
+
});
|
|
51
|
+
return async (ctx, next) => {
|
|
52
|
+
const { envelope } = ctx;
|
|
53
|
+
let tenantId = resolver(envelope);
|
|
54
|
+
if (!tenantId) {
|
|
55
|
+
if (required) {
|
|
56
|
+
throw new TenantResolutionError(
|
|
57
|
+
`Could not resolve tenant ID from message using strategy "${strategy}"`,
|
|
58
|
+
envelope
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
tenantId = defaultTenantId;
|
|
62
|
+
}
|
|
63
|
+
if (!tenantId) {
|
|
64
|
+
await next();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (allowedTenants && !allowedTenants.has(tenantId)) {
|
|
68
|
+
throw new TenantNotAllowedError(tenantId, envelope);
|
|
69
|
+
}
|
|
70
|
+
let originalSagaId;
|
|
71
|
+
if (prefixSagaId && ctx.sagaId) {
|
|
72
|
+
originalSagaId = ctx.sagaId;
|
|
73
|
+
ctx.sagaId = `${tenantId}:${ctx.sagaId}`;
|
|
74
|
+
}
|
|
75
|
+
await runWithTenant({ tenantId, originalSagaId }, async () => {
|
|
76
|
+
await next();
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function createTenantPublisher(options = {}) {
|
|
81
|
+
const headerName = options.headerName ?? "x-tenant-id";
|
|
82
|
+
return (envelope, tenantId) => {
|
|
83
|
+
return {
|
|
84
|
+
...envelope,
|
|
85
|
+
headers: {
|
|
86
|
+
...envelope.headers,
|
|
87
|
+
[headerName]: tenantId
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function createResolver(strategy, options) {
|
|
93
|
+
switch (strategy) {
|
|
94
|
+
case "header":
|
|
95
|
+
return (envelope) => {
|
|
96
|
+
return envelope.headers[options.headerName];
|
|
97
|
+
};
|
|
98
|
+
case "correlation-prefix":
|
|
99
|
+
return (envelope) => {
|
|
100
|
+
const partitionKey = envelope.partitionKey;
|
|
101
|
+
if (!partitionKey) {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
const separatorIndex = partitionKey.indexOf(options.correlationSeparator);
|
|
105
|
+
if (separatorIndex === -1) {
|
|
106
|
+
return void 0;
|
|
107
|
+
}
|
|
108
|
+
return partitionKey.slice(0, separatorIndex);
|
|
109
|
+
};
|
|
110
|
+
case "custom":
|
|
111
|
+
throw new Error("Custom strategy requires a resolver function");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export {
|
|
115
|
+
TenantNotAllowedError,
|
|
116
|
+
TenantResolutionError,
|
|
117
|
+
createTenantMiddleware,
|
|
118
|
+
createTenantPublisher,
|
|
119
|
+
getTenantId,
|
|
120
|
+
getTenantInfo,
|
|
121
|
+
requireTenantId,
|
|
122
|
+
runWithTenant
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts","../src/TenantContext.ts","../src/TenantMiddleware.ts"],"sourcesContent":["import type { MessageEnvelope } from \"@saga-bus/core\";\n\n/**\n * Tenant middleware configuration options.\n */\nexport interface TenantMiddlewareOptions {\n /**\n * Strategy for resolving tenant ID from messages.\n * @default \"header\"\n */\n strategy?: \"header\" | \"correlation-prefix\" | \"custom\";\n\n /**\n * Custom tenant resolver function.\n * Required when strategy is \"custom\".\n */\n resolver?: TenantResolver;\n\n /**\n * Header name for tenant ID when using \"header\" strategy.\n * @default \"x-tenant-id\"\n */\n headerName?: string;\n\n /**\n * Separator for correlation prefix strategy.\n * @default \":\"\n */\n correlationSeparator?: string;\n\n /**\n * Whether to require tenant ID on all messages.\n * @default true\n */\n required?: boolean;\n\n /**\n * Default tenant ID when not required and not found.\n */\n defaultTenantId?: string;\n\n /**\n * Allowlist of valid tenant IDs.\n * If provided, messages from unknown tenants are rejected.\n */\n allowedTenants?: string[];\n\n /**\n * Whether to prefix saga IDs with tenant ID for isolation.\n * @default true\n */\n prefixSagaId?: boolean;\n}\n\n/**\n * Function to resolve tenant ID from a message.\n */\nexport type TenantResolver = (envelope: MessageEnvelope) => string | undefined;\n\n/**\n * Tenant context for the current request.\n */\nexport interface TenantInfo {\n /**\n * Resolved tenant ID.\n */\n tenantId: string;\n\n /**\n * Original saga ID before tenant prefix.\n */\n originalSagaId?: string;\n}\n\n/**\n * Error thrown when tenant resolution fails.\n */\nexport class TenantResolutionError extends Error {\n constructor(\n message: string,\n public readonly envelope: MessageEnvelope\n ) {\n super(message);\n this.name = \"TenantResolutionError\";\n }\n}\n\n/**\n * Error thrown when tenant is not in allowlist.\n */\nexport class TenantNotAllowedError extends Error {\n constructor(\n public readonly tenantId: string,\n public readonly envelope: MessageEnvelope\n ) {\n super(`Tenant \"${tenantId}\" is not allowed`);\n this.name = \"TenantNotAllowedError\";\n }\n}\n","import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { TenantInfo } from \"./types.js\";\n\nconst tenantStorage = new AsyncLocalStorage<TenantInfo>();\n\n/**\n * Run a function within a tenant context.\n */\nexport function runWithTenant<T>(\n tenantInfo: TenantInfo,\n fn: () => T | Promise<T>\n): T | Promise<T> {\n return tenantStorage.run(tenantInfo, fn);\n}\n\n/**\n * Get the current tenant ID.\n * Returns undefined if not in a tenant context.\n */\nexport function getTenantId(): string | undefined {\n return tenantStorage.getStore()?.tenantId;\n}\n\n/**\n * Get the full tenant info for the current context.\n */\nexport function getTenantInfo(): TenantInfo | undefined {\n return tenantStorage.getStore();\n}\n\n/**\n * Get the current tenant ID or throw if not available.\n */\nexport function requireTenantId(): string {\n const tenantId = getTenantId();\n if (!tenantId) {\n throw new Error(\"No tenant context available\");\n }\n return tenantId;\n}\n","import type {\n SagaMiddleware,\n SagaPipelineContext,\n MessageEnvelope,\n} from \"@saga-bus/core\";\nimport type { TenantMiddlewareOptions, TenantResolver } from \"./types.js\";\nimport { TenantResolutionError, TenantNotAllowedError } from \"./types.js\";\nimport { runWithTenant } from \"./TenantContext.js\";\n\n/**\n * Create multi-tenant middleware for saga-bus.\n *\n * Resolves tenant ID from messages and provides tenant context\n * via AsyncLocalStorage for use within handlers.\n *\n * @example\n * ```typescript\n * const middleware = createTenantMiddleware({\n * strategy: \"header\",\n * headerName: \"x-tenant-id\",\n * allowedTenants: [\"tenant1\", \"tenant2\"],\n * });\n *\n * const bus = createMessageBus({\n * middleware: [middleware],\n * });\n * ```\n */\nexport function createTenantMiddleware(\n options: TenantMiddlewareOptions = {}\n): SagaMiddleware {\n const strategy = options.strategy ?? \"header\";\n const headerName = options.headerName ?? \"x-tenant-id\";\n const correlationSeparator = options.correlationSeparator ?? \":\";\n const required = options.required ?? true;\n const defaultTenantId = options.defaultTenantId;\n const allowedTenants = options.allowedTenants\n ? new Set(options.allowedTenants)\n : undefined;\n const prefixSagaId = options.prefixSagaId ?? true;\n\n const resolver =\n options.resolver ??\n createResolver(strategy, {\n headerName,\n correlationSeparator,\n });\n\n return async (ctx: SagaPipelineContext, next: () => Promise<void>) => {\n const { envelope } = ctx;\n\n // Resolve tenant ID\n let tenantId = resolver(envelope);\n\n if (!tenantId) {\n if (required) {\n throw new TenantResolutionError(\n `Could not resolve tenant ID from message using strategy \"${strategy}\"`,\n envelope\n );\n }\n tenantId = defaultTenantId;\n }\n\n if (!tenantId) {\n // No tenant and not required - proceed without tenant context\n await next();\n return;\n }\n\n // Validate against allowlist\n if (allowedTenants && !allowedTenants.has(tenantId)) {\n throw new TenantNotAllowedError(tenantId, envelope);\n }\n\n // Modify saga ID if needed\n let originalSagaId: string | undefined;\n if (prefixSagaId && ctx.sagaId) {\n originalSagaId = ctx.sagaId;\n ctx.sagaId = `${tenantId}:${ctx.sagaId}`;\n }\n\n // Run handler within tenant context\n await runWithTenant({ tenantId, originalSagaId }, async () => {\n await next();\n });\n };\n}\n\n/**\n * Create a publish interceptor that adds tenant ID to outbound messages.\n *\n * @example\n * ```typescript\n * const publisher = createTenantPublisher();\n * const envelope = publisher(originalEnvelope, \"tenant1\");\n * ```\n */\nexport function createTenantPublisher(\n options: Pick<TenantMiddlewareOptions, \"headerName\"> = {}\n): (envelope: MessageEnvelope, tenantId: string) => MessageEnvelope {\n const headerName = options.headerName ?? \"x-tenant-id\";\n\n return (envelope: MessageEnvelope, tenantId: string): MessageEnvelope => {\n return {\n ...envelope,\n headers: {\n ...envelope.headers,\n [headerName]: tenantId,\n },\n };\n };\n}\n\nfunction createResolver(\n strategy: \"header\" | \"correlation-prefix\" | \"custom\",\n options: {\n headerName: string;\n correlationSeparator: string;\n }\n): TenantResolver {\n switch (strategy) {\n case \"header\":\n return (envelope: MessageEnvelope) => {\n return envelope.headers[options.headerName];\n };\n\n case \"correlation-prefix\":\n return (envelope: MessageEnvelope) => {\n const partitionKey = envelope.partitionKey;\n if (!partitionKey) {\n return undefined;\n }\n const separatorIndex = partitionKey.indexOf(options.correlationSeparator);\n if (separatorIndex === -1) {\n return undefined;\n }\n return partitionKey.slice(0, separatorIndex);\n };\n\n case \"custom\":\n throw new Error(\"Custom strategy requires a resolver function\");\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saga-bus/middleware-tenant",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-tenant middleware for saga-bus",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/deanforan/saga-bus.git",
|
|
26
|
+
"directory": "packages/middleware-tenant"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"saga",
|
|
30
|
+
"message-bus",
|
|
31
|
+
"middleware",
|
|
32
|
+
"multi-tenant",
|
|
33
|
+
"tenant"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@saga-bus/core": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.0.0",
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.9.2",
|
|
42
|
+
"vitest": "^3.0.0",
|
|
43
|
+
"@repo/eslint-config": "0.0.0",
|
|
44
|
+
"@repo/typescript-config": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@saga-bus/core": ">=0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"lint": "eslint src/",
|
|
53
|
+
"check-types": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest"
|
|
56
|
+
}
|
|
57
|
+
}
|