@devstroupe/devkit-nest 1.1.16 → 1.2.0-beta.1
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/README.md +35 -55
- package/dist/application/crud-use-cases.d.ts +35 -0
- package/dist/application/crud-use-cases.js +44 -0
- package/dist/application/crud-use-cases.test.d.ts +1 -0
- package/dist/application/crud-use-cases.test.js +19 -0
- package/dist/application/index.d.ts +2 -0
- package/dist/application/index.js +18 -0
- package/dist/application/read-model.d.ts +5 -0
- package/dist/application/read-model.js +2 -0
- package/dist/context/execution-context.d.ts +19 -0
- package/dist/context/execution-context.interceptor.d.ts +5 -0
- package/dist/context/execution-context.interceptor.js +35 -0
- package/dist/context/execution-context.interceptor.test.d.ts +1 -0
- package/dist/context/execution-context.interceptor.test.js +26 -0
- package/dist/context/execution-context.js +38 -0
- package/dist/context/execution-context.test.d.ts +1 -0
- package/dist/context/execution-context.test.js +18 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +18 -0
- package/dist/database/base.repository.d.ts +25 -13
- package/dist/database/base.repository.js +63 -29
- package/dist/database/base.repository.test.d.ts +1 -0
- package/dist/database/base.repository.test.js +46 -0
- package/dist/database/typeorm-explain.d.ts +3 -0
- package/dist/database/typeorm-explain.js +8 -0
- package/dist/database/typeorm-explain.test.d.ts +1 -0
- package/dist/database/typeorm-explain.test.js +15 -0
- package/dist/database/typeorm-query.builder.d.ts +36 -8
- package/dist/database/typeorm-query.builder.js +150 -165
- package/dist/database/typeorm-query.builder.test.js +32 -1
- package/dist/database/typeorm-reader.d.ts +9 -0
- package/dist/database/typeorm-reader.js +49 -0
- package/dist/database/typeorm-reader.test.d.ts +1 -0
- package/dist/database/typeorm-reader.test.js +55 -0
- package/dist/database/typeorm-sql.d.ts +3 -0
- package/dist/database/typeorm-sql.js +7 -0
- package/dist/database/typeorm-sql.test.d.ts +1 -0
- package/dist/database/typeorm-sql.test.js +21 -0
- package/dist/database/typeorm-unit-of-work.d.ts +13 -0
- package/dist/database/typeorm-unit-of-work.js +16 -0
- package/dist/database/typeorm-unit-of-work.test.d.ts +1 -0
- package/dist/database/typeorm-unit-of-work.test.js +34 -0
- package/dist/http/base-crud.controller.d.ts +26 -10
- package/dist/http/base-crud.controller.js +46 -36
- package/dist/http/base-crud.controller.test.d.ts +1 -0
- package/dist/http/base-crud.controller.test.js +18 -0
- package/dist/http/module-http-proxy.d.ts +16 -0
- package/dist/http/module-http-proxy.js +160 -0
- package/dist/http/module-http-proxy.test.d.ts +1 -0
- package/dist/http/module-http-proxy.test.js +37 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/platform/index.d.ts +3 -0
- package/dist/platform/index.js +19 -0
- package/dist/platform/integrations/company-registry.d.ts +16 -0
- package/dist/platform/integrations/company-registry.js +2 -0
- package/dist/platform/integrations/external-http.d.ts +38 -0
- package/dist/platform/integrations/external-http.js +78 -0
- package/dist/platform/integrations/external-http.test.d.ts +1 -0
- package/dist/platform/integrations/external-http.test.js +42 -0
- package/dist/platform/integrations/index.d.ts +3 -0
- package/dist/platform/integrations/index.js +19 -0
- package/dist/platform/integrations/webhook.d.ts +45 -0
- package/dist/platform/integrations/webhook.js +74 -0
- package/dist/platform/integrations/webhook.test.d.ts +1 -0
- package/dist/platform/integrations/webhook.test.js +66 -0
- package/dist/platform/messaging/index.d.ts +1 -0
- package/dist/platform/messaging/index.js +17 -0
- package/dist/platform/messaging/outbox.d.ts +56 -0
- package/dist/platform/messaging/outbox.js +90 -0
- package/dist/platform/messaging/outbox.test.d.ts +1 -0
- package/dist/platform/messaging/outbox.test.js +65 -0
- package/dist/platform/security/index.d.ts +2 -0
- package/dist/platform/security/index.js +18 -0
- package/dist/platform/security/password-hasher.d.ts +14 -0
- package/dist/platform/security/password-hasher.js +20 -0
- package/dist/platform/security/security.test.d.ts +1 -0
- package/dist/platform/security/security.test.js +24 -0
- package/dist/platform/security/token.d.ts +22 -0
- package/dist/platform/security/token.js +44 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +1 -0
- package/dist/realtime/realtime.gateway.d.ts +4 -1
- package/dist/realtime/realtime.gateway.js +10 -38
- package/dist/realtime/realtime.gateway.test.d.ts +1 -0
- package/dist/realtime/realtime.gateway.test.js +34 -0
- package/dist/realtime/realtime.module.d.ts +7 -0
- package/dist/realtime/realtime.module.js +17 -6
- package/dist/realtime/realtime.tokens.d.ts +1 -0
- package/dist/realtime/realtime.tokens.js +4 -0
- package/dist/storage/drivers/local.storage.js +8 -1
- package/package.json +18 -14
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const external_http_1 = require("./external-http");
|
|
9
|
+
(0, node_test_1.default)('external capability applies correlation, retry and neutral response parsing', async () => {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const fetcher = async (_url, init) => {
|
|
12
|
+
calls.push(init ?? {});
|
|
13
|
+
if (calls.length === 1)
|
|
14
|
+
throw new Error('temporary network failure');
|
|
15
|
+
return new Response(JSON.stringify({ legalName: 'Example Ltd' }), {
|
|
16
|
+
status: 200, headers: { 'content-type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
const metrics = [];
|
|
20
|
+
const logs = [];
|
|
21
|
+
const client = new external_http_1.ExternalHttpClient({ timeoutMs: 100, maxAttempts: 2, retryDelayMs: 0 }, { record: (...args) => metrics.push(args) }, fetcher, { record: (entry) => logs.push(entry) });
|
|
22
|
+
const response = await client.json('company-registry', 'https://provider.invalid/company/1', {}, { correlationId: 'corr-http' });
|
|
23
|
+
strict_1.default.equal(response.legalName, 'Example Ltd');
|
|
24
|
+
strict_1.default.equal(calls[1].headers['x-correlation-id'], 'corr-http');
|
|
25
|
+
strict_1.default.equal(metrics[0][1], 'success');
|
|
26
|
+
strict_1.default.deepEqual(logs.map(({ outcome }) => outcome), ['retry', 'success']);
|
|
27
|
+
strict_1.default.equal(logs[0].correlationId, 'corr-http');
|
|
28
|
+
strict_1.default.equal('url' in logs[0], false);
|
|
29
|
+
strict_1.default.equal('headers' in logs[0], false);
|
|
30
|
+
});
|
|
31
|
+
(0, node_test_1.default)('telemetry failure does not retry a successful external request', async () => {
|
|
32
|
+
let calls = 0;
|
|
33
|
+
const fetcher = async () => {
|
|
34
|
+
calls += 1;
|
|
35
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
36
|
+
};
|
|
37
|
+
const client = new external_http_1.ExternalHttpClient({ timeoutMs: 100, maxAttempts: 2, retryDelayMs: 0 }, { record: () => { throw new Error('metrics unavailable'); } }, fetcher, { record: () => { throw new Error('logs unavailable'); } });
|
|
38
|
+
strict_1.default.deepEqual(await client.json('company-registry', 'https://provider.invalid', {}, {
|
|
39
|
+
correlationId: 'corr-safe-observability',
|
|
40
|
+
}), { ok: true });
|
|
41
|
+
strict_1.default.equal(calls, 1);
|
|
42
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./company-registry"), exports);
|
|
18
|
+
__exportStar(require("./external-http"), exports);
|
|
19
|
+
__exportStar(require("./webhook"), exports);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface WebhookRequest {
|
|
2
|
+
body: Uint8Array;
|
|
3
|
+
headers: Readonly<Record<string, string | undefined>>;
|
|
4
|
+
receivedAt: Date;
|
|
5
|
+
correlationId: string;
|
|
6
|
+
}
|
|
7
|
+
export interface VerifiedWebhook<Payload> {
|
|
8
|
+
eventId: string;
|
|
9
|
+
payload: Payload;
|
|
10
|
+
tenantId?: string;
|
|
11
|
+
actorId?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface WebhookVerifier<Payload> {
|
|
14
|
+
verify(request: WebhookRequest): Promise<VerifiedWebhook<Payload>>;
|
|
15
|
+
}
|
|
16
|
+
export interface WebhookIdempotencyStore {
|
|
17
|
+
/** Atomic: returns false when this event was already accepted. */
|
|
18
|
+
acquire(eventId: string): Promise<boolean>;
|
|
19
|
+
release(eventId: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export type WebhookAuditOutcome = 'processed' | 'duplicate' | 'rejected' | 'failed';
|
|
22
|
+
export interface WebhookAuditRecord {
|
|
23
|
+
/** Payload, signature and provider secrets are deliberately absent. */
|
|
24
|
+
eventId?: string;
|
|
25
|
+
correlationId: string;
|
|
26
|
+
outcome: WebhookAuditOutcome;
|
|
27
|
+
occurredAt: Date;
|
|
28
|
+
}
|
|
29
|
+
export interface WebhookAuditSink {
|
|
30
|
+
record(entry: WebhookAuditRecord): void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare class WebhookReplayError extends Error {
|
|
33
|
+
constructor(message?: string);
|
|
34
|
+
}
|
|
35
|
+
/** Call from a provider verifier after signature validation and before returning its payload. */
|
|
36
|
+
export declare function assertWebhookFresh(providerTimestamp: Date, receivedAt: Date, maxAgeMs: number, allowedFutureSkewMs?: number): void;
|
|
37
|
+
export declare class WebhookProcessor<Payload> {
|
|
38
|
+
private readonly verifier;
|
|
39
|
+
private readonly idempotency;
|
|
40
|
+
private readonly handle;
|
|
41
|
+
private readonly audit?;
|
|
42
|
+
constructor(verifier: WebhookVerifier<Payload>, idempotency: WebhookIdempotencyStore, handle: (payload: Payload) => Promise<void>, audit?: WebhookAuditSink | undefined);
|
|
43
|
+
process(request: WebhookRequest): Promise<'processed' | 'duplicate'>;
|
|
44
|
+
private record;
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebhookProcessor = exports.WebhookReplayError = void 0;
|
|
4
|
+
exports.assertWebhookFresh = assertWebhookFresh;
|
|
5
|
+
const context_1 = require("../../context");
|
|
6
|
+
class WebhookReplayError extends Error {
|
|
7
|
+
constructor(message = 'Webhook timestamp is outside the accepted replay window.') {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'WebhookReplayError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.WebhookReplayError = WebhookReplayError;
|
|
13
|
+
/** Call from a provider verifier after signature validation and before returning its payload. */
|
|
14
|
+
function assertWebhookFresh(providerTimestamp, receivedAt, maxAgeMs, allowedFutureSkewMs = 30_000) {
|
|
15
|
+
const age = receivedAt.getTime() - providerTimestamp.getTime();
|
|
16
|
+
if (!Number.isFinite(age) || age > maxAgeMs || age < -allowedFutureSkewMs) {
|
|
17
|
+
throw new WebhookReplayError();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class WebhookProcessor {
|
|
21
|
+
verifier;
|
|
22
|
+
idempotency;
|
|
23
|
+
handle;
|
|
24
|
+
audit;
|
|
25
|
+
constructor(verifier, idempotency, handle, audit) {
|
|
26
|
+
this.verifier = verifier;
|
|
27
|
+
this.idempotency = idempotency;
|
|
28
|
+
this.handle = handle;
|
|
29
|
+
this.audit = audit;
|
|
30
|
+
}
|
|
31
|
+
async process(request) {
|
|
32
|
+
let verified;
|
|
33
|
+
try {
|
|
34
|
+
verified = await this.verifier.verify(request);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await this.record(request, 'rejected');
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (!await this.idempotency.acquire(verified.eventId)) {
|
|
41
|
+
await this.record(request, 'duplicate', verified.eventId);
|
|
42
|
+
return 'duplicate';
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await context_1.executionContext.run({
|
|
46
|
+
actorId: verified.actorId,
|
|
47
|
+
tenantId: verified.tenantId,
|
|
48
|
+
correlationId: request.correlationId,
|
|
49
|
+
source: 'event',
|
|
50
|
+
}, () => this.handle(verified.payload));
|
|
51
|
+
await this.record(request, 'processed', verified.eventId);
|
|
52
|
+
return 'processed';
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
await this.idempotency.release(verified.eventId);
|
|
56
|
+
await this.record(request, 'failed', verified.eventId);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async record(request, outcome, eventId) {
|
|
61
|
+
try {
|
|
62
|
+
await this.audit?.record({
|
|
63
|
+
eventId,
|
|
64
|
+
correlationId: request.correlationId,
|
|
65
|
+
outcome,
|
|
66
|
+
occurredAt: new Date(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Observability must never release idempotency or repeat an accepted effect.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.WebhookProcessor = WebhookProcessor;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const context_1 = require("../../context");
|
|
9
|
+
const webhook_1 = require("./webhook");
|
|
10
|
+
(0, node_test_1.default)('webhook verification runs before application effects and duplicates are ignored', async () => {
|
|
11
|
+
const accepted = new Set();
|
|
12
|
+
let effects = 0;
|
|
13
|
+
const verifier = {
|
|
14
|
+
async verify(request) {
|
|
15
|
+
if (request.headers.signature !== 'valid')
|
|
16
|
+
throw new Error('invalid signature');
|
|
17
|
+
return { eventId: 'provider-event-1', payload: { invoiceId: 1 } };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const store = {
|
|
21
|
+
async acquire(id) { if (accepted.has(id))
|
|
22
|
+
return false; accepted.add(id); return true; },
|
|
23
|
+
async release(id) { accepted.delete(id); },
|
|
24
|
+
};
|
|
25
|
+
const audit = [];
|
|
26
|
+
const processor = new webhook_1.WebhookProcessor(verifier, store, async () => {
|
|
27
|
+
effects += 1;
|
|
28
|
+
strict_1.default.equal(context_1.executionContext.requireCurrent().source, 'event');
|
|
29
|
+
strict_1.default.equal(context_1.executionContext.requireCurrent().correlationId, 'corr-webhook');
|
|
30
|
+
}, { record: (entry) => { audit.push(entry); } });
|
|
31
|
+
const request = {
|
|
32
|
+
body: new Uint8Array(), headers: { signature: 'valid' },
|
|
33
|
+
receivedAt: new Date(), correlationId: 'corr-webhook',
|
|
34
|
+
};
|
|
35
|
+
strict_1.default.equal(await processor.process(request), 'processed');
|
|
36
|
+
strict_1.default.equal(await processor.process(request), 'duplicate');
|
|
37
|
+
strict_1.default.equal(effects, 1);
|
|
38
|
+
await strict_1.default.rejects(() => processor.process({ ...request, headers: {} }), /invalid signature/);
|
|
39
|
+
strict_1.default.equal(effects, 1);
|
|
40
|
+
strict_1.default.deepEqual(audit.map(({ outcome }) => outcome), ['processed', 'duplicate', 'rejected']);
|
|
41
|
+
strict_1.default.equal('body' in audit[0], false);
|
|
42
|
+
strict_1.default.equal('headers' in audit[0], false);
|
|
43
|
+
});
|
|
44
|
+
(0, node_test_1.default)('webhook replay protection rejects stale and excessively future timestamps', () => {
|
|
45
|
+
const receivedAt = new Date('2026-07-14T12:00:00.000Z');
|
|
46
|
+
strict_1.default.doesNotThrow(() => (0, webhook_1.assertWebhookFresh)(new Date('2026-07-14T11:59:30.000Z'), receivedAt, 60_000));
|
|
47
|
+
strict_1.default.throws(() => (0, webhook_1.assertWebhookFresh)(new Date('2026-07-14T11:58:00.000Z'), receivedAt, 60_000), webhook_1.WebhookReplayError);
|
|
48
|
+
strict_1.default.throws(() => (0, webhook_1.assertWebhookFresh)(new Date('2026-07-14T12:01:00.000Z'), receivedAt, 60_000, 30_000), webhook_1.WebhookReplayError);
|
|
49
|
+
});
|
|
50
|
+
(0, node_test_1.default)('webhook audit failure cannot release idempotency and duplicate an effect', async () => {
|
|
51
|
+
let effects = 0;
|
|
52
|
+
let releases = 0;
|
|
53
|
+
const accepted = new Set();
|
|
54
|
+
const processor = new webhook_1.WebhookProcessor({ verify: async () => ({ eventId: 'event-safe', payload: {} }) }, {
|
|
55
|
+
acquire: async (id) => { if (accepted.has(id))
|
|
56
|
+
return false; accepted.add(id); return true; },
|
|
57
|
+
release: async () => { releases += 1; },
|
|
58
|
+
}, async () => { effects += 1; }, { record: () => { throw new Error('audit unavailable'); } });
|
|
59
|
+
const request = {
|
|
60
|
+
body: new Uint8Array(), headers: {}, receivedAt: new Date(), correlationId: 'corr-audit',
|
|
61
|
+
};
|
|
62
|
+
strict_1.default.equal(await processor.process(request), 'processed');
|
|
63
|
+
strict_1.default.equal(await processor.process(request), 'duplicate');
|
|
64
|
+
strict_1.default.equal(effects, 1);
|
|
65
|
+
strict_1.default.equal(releases, 0);
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './outbox';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./outbox"), exports);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EntityManager, EntityTarget, ObjectLiteral } from 'typeorm';
|
|
2
|
+
import type { AppExecutionContext } from '../../context';
|
|
3
|
+
export interface IntegrationEvent<Payload = unknown> {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
occurredAt: string;
|
|
7
|
+
payload: Payload;
|
|
8
|
+
tenantId?: string;
|
|
9
|
+
correlationId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface OutboxRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
eventType: string;
|
|
14
|
+
payload: unknown;
|
|
15
|
+
tenantId?: string;
|
|
16
|
+
correlationId: string;
|
|
17
|
+
occurredAt: Date;
|
|
18
|
+
publishedAt?: Date;
|
|
19
|
+
attempts: number;
|
|
20
|
+
lastError?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function integrationEvent<Payload>(type: string, payload: Payload, context: Pick<AppExecutionContext, 'tenantId' | 'correlationId'>): IntegrationEvent<Payload>;
|
|
23
|
+
/** Must receive the same transactional manager used for the business write. */
|
|
24
|
+
export declare class TypeOrmOutboxWriter {
|
|
25
|
+
private readonly recordTarget;
|
|
26
|
+
constructor(recordTarget: EntityTarget<ObjectLiteral>);
|
|
27
|
+
append(manager: EntityManager, event: IntegrationEvent): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface OutboxStore {
|
|
30
|
+
pending(limit: number): Promise<OutboxRecord[]>;
|
|
31
|
+
markPublished(id: string, publishedAt: Date): Promise<void>;
|
|
32
|
+
markFailed(id: string, error: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface IntegrationEventBroker {
|
|
35
|
+
/** The event ID is the consumer idempotency key. */
|
|
36
|
+
publish(event: IntegrationEvent): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface InboxIdempotencyStore {
|
|
39
|
+
acquire(eventId: string): Promise<boolean>;
|
|
40
|
+
release(eventId: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export declare class IdempotentEventConsumer {
|
|
43
|
+
private readonly inbox;
|
|
44
|
+
constructor(inbox: InboxIdempotencyStore);
|
|
45
|
+
consume(event: IntegrationEvent, effect: (event: IntegrationEvent) => Promise<void>): Promise<'processed' | 'duplicate'>;
|
|
46
|
+
}
|
|
47
|
+
export declare class OutboxPublisher {
|
|
48
|
+
private readonly store;
|
|
49
|
+
private readonly broker;
|
|
50
|
+
private readonly batchSize;
|
|
51
|
+
constructor(store: OutboxStore, broker: IntegrationEventBroker, batchSize?: number);
|
|
52
|
+
publishPending(): Promise<{
|
|
53
|
+
published: number;
|
|
54
|
+
failed: number;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OutboxPublisher = exports.IdempotentEventConsumer = exports.TypeOrmOutboxWriter = void 0;
|
|
4
|
+
exports.integrationEvent = integrationEvent;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const context_1 = require("../../context");
|
|
7
|
+
function integrationEvent(type, payload, context) {
|
|
8
|
+
return {
|
|
9
|
+
id: (0, node_crypto_1.randomUUID)(), type, payload, tenantId: context.tenantId,
|
|
10
|
+
correlationId: context.correlationId, occurredAt: new Date().toISOString(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Must receive the same transactional manager used for the business write. */
|
|
14
|
+
class TypeOrmOutboxWriter {
|
|
15
|
+
recordTarget;
|
|
16
|
+
constructor(recordTarget) {
|
|
17
|
+
this.recordTarget = recordTarget;
|
|
18
|
+
}
|
|
19
|
+
async append(manager, event) {
|
|
20
|
+
const record = {
|
|
21
|
+
id: event.id,
|
|
22
|
+
eventType: event.type,
|
|
23
|
+
payload: event.payload,
|
|
24
|
+
tenantId: event.tenantId,
|
|
25
|
+
correlationId: event.correlationId,
|
|
26
|
+
occurredAt: new Date(event.occurredAt),
|
|
27
|
+
attempts: 0,
|
|
28
|
+
};
|
|
29
|
+
await manager.insert(this.recordTarget, record);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.TypeOrmOutboxWriter = TypeOrmOutboxWriter;
|
|
33
|
+
class IdempotentEventConsumer {
|
|
34
|
+
inbox;
|
|
35
|
+
constructor(inbox) {
|
|
36
|
+
this.inbox = inbox;
|
|
37
|
+
}
|
|
38
|
+
async consume(event, effect) {
|
|
39
|
+
if (!await this.inbox.acquire(event.id))
|
|
40
|
+
return 'duplicate';
|
|
41
|
+
try {
|
|
42
|
+
await context_1.executionContext.run({
|
|
43
|
+
tenantId: event.tenantId,
|
|
44
|
+
correlationId: event.correlationId,
|
|
45
|
+
source: 'event',
|
|
46
|
+
}, () => effect(event));
|
|
47
|
+
return 'processed';
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await this.inbox.release(event.id);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.IdempotentEventConsumer = IdempotentEventConsumer;
|
|
56
|
+
class OutboxPublisher {
|
|
57
|
+
store;
|
|
58
|
+
broker;
|
|
59
|
+
batchSize;
|
|
60
|
+
constructor(store, broker, batchSize = 50) {
|
|
61
|
+
this.store = store;
|
|
62
|
+
this.broker = broker;
|
|
63
|
+
this.batchSize = batchSize;
|
|
64
|
+
}
|
|
65
|
+
async publishPending() {
|
|
66
|
+
const records = await this.store.pending(this.batchSize);
|
|
67
|
+
let published = 0;
|
|
68
|
+
let failed = 0;
|
|
69
|
+
for (const record of records) {
|
|
70
|
+
try {
|
|
71
|
+
await this.broker.publish({
|
|
72
|
+
id: record.id,
|
|
73
|
+
type: record.eventType,
|
|
74
|
+
payload: record.payload,
|
|
75
|
+
tenantId: record.tenantId,
|
|
76
|
+
correlationId: record.correlationId,
|
|
77
|
+
occurredAt: record.occurredAt.toISOString(),
|
|
78
|
+
});
|
|
79
|
+
await this.store.markPublished(record.id, new Date());
|
|
80
|
+
published += 1;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await this.store.markFailed(record.id, error instanceof Error ? error.message : String(error));
|
|
84
|
+
failed += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { published, failed };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.OutboxPublisher = OutboxPublisher;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const context_1 = require("../../context");
|
|
9
|
+
const outbox_1 = require("./outbox");
|
|
10
|
+
(0, node_test_1.default)('outbox writer uses the caller transactional manager and carries tenant/correlation', async () => {
|
|
11
|
+
const inserted = [];
|
|
12
|
+
const manager = { insert: async (...args) => { inserted.push(args); } };
|
|
13
|
+
const event = (0, outbox_1.integrationEvent)('invoice.created', { invoiceId: 1 }, {
|
|
14
|
+
tenantId: 'tenant-1', correlationId: 'corr-1',
|
|
15
|
+
});
|
|
16
|
+
await new outbox_1.TypeOrmOutboxWriter('outbox').append(manager, event);
|
|
17
|
+
strict_1.default.equal(inserted.length, 1);
|
|
18
|
+
strict_1.default.equal(inserted[0][1].tenantId, 'tenant-1');
|
|
19
|
+
strict_1.default.equal(inserted[0][1].correlationId, 'corr-1');
|
|
20
|
+
strict_1.default.equal(inserted[0][1].id, event.id);
|
|
21
|
+
});
|
|
22
|
+
(0, node_test_1.default)('outbox keeps a failed event pending and publishes it idempotently on retry', async () => {
|
|
23
|
+
const record = {
|
|
24
|
+
id: 'event-1', eventType: 'invoice.created', payload: { invoiceId: 1 },
|
|
25
|
+
correlationId: 'corr-1', tenantId: 'tenant-1', occurredAt: new Date(), attempts: 0,
|
|
26
|
+
};
|
|
27
|
+
let pending = true;
|
|
28
|
+
let brokerCalls = 0;
|
|
29
|
+
const store = {
|
|
30
|
+
async pending() { return pending ? [record] : []; },
|
|
31
|
+
async markPublished() { pending = false; },
|
|
32
|
+
async markFailed() { record.attempts += 1; },
|
|
33
|
+
};
|
|
34
|
+
const broker = {
|
|
35
|
+
async publish(event) {
|
|
36
|
+
brokerCalls += 1;
|
|
37
|
+
strict_1.default.equal(event.id, 'event-1');
|
|
38
|
+
if (brokerCalls === 1)
|
|
39
|
+
throw new Error('broker unavailable');
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const publisher = new outbox_1.OutboxPublisher(store, broker);
|
|
43
|
+
strict_1.default.deepEqual(await publisher.publishPending(), { published: 0, failed: 1 });
|
|
44
|
+
strict_1.default.deepEqual(await publisher.publishPending(), { published: 1, failed: 0 });
|
|
45
|
+
strict_1.default.deepEqual(await publisher.publishPending(), { published: 0, failed: 0 });
|
|
46
|
+
strict_1.default.equal(brokerCalls, 2);
|
|
47
|
+
});
|
|
48
|
+
(0, node_test_1.default)('consumer inbox prevents a redelivered event from duplicating its effect', async () => {
|
|
49
|
+
const accepted = new Set();
|
|
50
|
+
const inbox = {
|
|
51
|
+
async acquire(id) { if (accepted.has(id))
|
|
52
|
+
return false; accepted.add(id); return true; },
|
|
53
|
+
async release(id) { accepted.delete(id); },
|
|
54
|
+
};
|
|
55
|
+
const consumer = new outbox_1.IdempotentEventConsumer(inbox);
|
|
56
|
+
const event = (0, outbox_1.integrationEvent)('payment.confirmed', { paymentId: 1 }, { correlationId: 'corr-2' });
|
|
57
|
+
let effects = 0;
|
|
58
|
+
strict_1.default.equal(await consumer.consume(event, async () => {
|
|
59
|
+
effects += 1;
|
|
60
|
+
strict_1.default.equal(context_1.executionContext.requireCurrent().source, 'event');
|
|
61
|
+
strict_1.default.equal(context_1.executionContext.requireCurrent().correlationId, 'corr-2');
|
|
62
|
+
}), 'processed');
|
|
63
|
+
strict_1.default.equal(await consumer.consume(event, async () => { effects += 1; }), 'duplicate');
|
|
64
|
+
strict_1.default.equal(effects, 1);
|
|
65
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./password-hasher"), exports);
|
|
18
|
+
__exportStar(require("./token"), exports);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PasswordHasher {
|
|
2
|
+
hash(plainText: string): Promise<string>;
|
|
3
|
+
verify(plainText: string, encodedHash: string): Promise<boolean>;
|
|
4
|
+
}
|
|
5
|
+
export declare class BcryptPasswordHasher implements PasswordHasher {
|
|
6
|
+
private readonly rounds;
|
|
7
|
+
constructor(rounds?: number);
|
|
8
|
+
hash(plainText: string): Promise<string>;
|
|
9
|
+
verify(plainText: string, encodedHash: string): Promise<boolean>;
|
|
10
|
+
}
|
|
11
|
+
export declare class InMemoryPasswordHasher implements PasswordHasher {
|
|
12
|
+
hash(plainText: string): Promise<string>;
|
|
13
|
+
verify(plainText: string, encodedHash: string): Promise<boolean>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryPasswordHasher = exports.BcryptPasswordHasher = void 0;
|
|
4
|
+
const bcryptjs_1 = require("bcryptjs");
|
|
5
|
+
class BcryptPasswordHasher {
|
|
6
|
+
rounds;
|
|
7
|
+
constructor(rounds = 12) {
|
|
8
|
+
this.rounds = rounds;
|
|
9
|
+
if (rounds < 10)
|
|
10
|
+
throw new Error('Bcrypt cost must be at least 10.');
|
|
11
|
+
}
|
|
12
|
+
hash(plainText) { return (0, bcryptjs_1.hash)(plainText, this.rounds); }
|
|
13
|
+
verify(plainText, encodedHash) { return (0, bcryptjs_1.compare)(plainText, encodedHash); }
|
|
14
|
+
}
|
|
15
|
+
exports.BcryptPasswordHasher = BcryptPasswordHasher;
|
|
16
|
+
class InMemoryPasswordHasher {
|
|
17
|
+
hash(plainText) { return Promise.resolve(`test:${plainText}`); }
|
|
18
|
+
verify(plainText, encodedHash) { return Promise.resolve(encodedHash === `test:${plainText}`); }
|
|
19
|
+
}
|
|
20
|
+
exports.InMemoryPasswordHasher = InMemoryPasswordHasher;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const password_hasher_1 = require("./password-hasher");
|
|
9
|
+
const token_1 = require("./token");
|
|
10
|
+
(0, node_test_1.default)('password hashing is replaceable and bcrypt never stores plain text', async () => {
|
|
11
|
+
const bcrypt = new password_hasher_1.BcryptPasswordHasher(10);
|
|
12
|
+
const encoded = await bcrypt.hash('secret-value');
|
|
13
|
+
strict_1.default.notEqual(encoded, 'secret-value');
|
|
14
|
+
strict_1.default.equal(await bcrypt.verify('secret-value', encoded), true);
|
|
15
|
+
strict_1.default.equal(await new password_hasher_1.InMemoryPasswordHasher().verify('secret-value', 'test:secret-value'), true);
|
|
16
|
+
});
|
|
17
|
+
(0, node_test_1.default)('token issuing and verification are separate capabilities', async () => {
|
|
18
|
+
const secret = 'a-development-secret-with-at-least-32-characters';
|
|
19
|
+
const issuer = new token_1.JwtTokenIssuer(secret, { algorithm: 'HS256', expiresIn: '1m' });
|
|
20
|
+
const verifier = new token_1.JwtTokenVerifier((0, token_1.staticVerificationKey)(secret), ['HS256']);
|
|
21
|
+
const token = await issuer.issue({ sub: 'user-1' });
|
|
22
|
+
strict_1.default.equal((await verifier.verify(token)).sub, 'user-1');
|
|
23
|
+
strict_1.default.equal('issue' in verifier, false);
|
|
24
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Algorithm, JwtHeader, JwtPayload, SignOptions } from 'jsonwebtoken';
|
|
2
|
+
export interface TokenIssuer<Claims extends object = Record<string, unknown>> {
|
|
3
|
+
issue(claims: Claims): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
export interface TokenVerifier<Claims extends object = Record<string, unknown>> {
|
|
6
|
+
verify(token: string): Promise<Claims & JwtPayload>;
|
|
7
|
+
}
|
|
8
|
+
export type VerificationKeyResolver = (header: JwtHeader) => Promise<string | Buffer>;
|
|
9
|
+
export declare class JwtTokenIssuer<Claims extends object = Record<string, unknown>> implements TokenIssuer<Claims> {
|
|
10
|
+
private readonly privateKey;
|
|
11
|
+
private readonly options;
|
|
12
|
+
constructor(privateKey: string | Buffer, options?: SignOptions);
|
|
13
|
+
issue(claims: Claims): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
/** Consumers receive only this verifier and a public/JWKS key resolver. */
|
|
16
|
+
export declare class JwtTokenVerifier<Claims extends object = Record<string, unknown>> implements TokenVerifier<Claims> {
|
|
17
|
+
private readonly resolveKey;
|
|
18
|
+
private readonly algorithms;
|
|
19
|
+
constructor(resolveKey: VerificationKeyResolver, algorithms?: Algorithm[]);
|
|
20
|
+
verify(token: string): Promise<Claims & JwtPayload>;
|
|
21
|
+
}
|
|
22
|
+
export declare function staticVerificationKey(key: string | Buffer): VerificationKeyResolver;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.JwtTokenVerifier = exports.JwtTokenIssuer = void 0;
|
|
7
|
+
exports.staticVerificationKey = staticVerificationKey;
|
|
8
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
|
+
class JwtTokenIssuer {
|
|
10
|
+
privateKey;
|
|
11
|
+
options;
|
|
12
|
+
constructor(privateKey, options = { algorithm: 'RS256', expiresIn: '15m' }) {
|
|
13
|
+
this.privateKey = privateKey;
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
issue(claims) {
|
|
17
|
+
return Promise.resolve(jsonwebtoken_1.default.sign(claims, this.privateKey, this.options));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.JwtTokenIssuer = JwtTokenIssuer;
|
|
21
|
+
/** Consumers receive only this verifier and a public/JWKS key resolver. */
|
|
22
|
+
class JwtTokenVerifier {
|
|
23
|
+
resolveKey;
|
|
24
|
+
algorithms;
|
|
25
|
+
constructor(resolveKey, algorithms = ['RS256']) {
|
|
26
|
+
this.resolveKey = resolveKey;
|
|
27
|
+
this.algorithms = algorithms;
|
|
28
|
+
}
|
|
29
|
+
verify(token) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
jsonwebtoken_1.default.verify(token, (header, done) => this.resolveKey(header).then((key) => done(null, key), done), { algorithms: this.algorithms }, (error, decoded) => {
|
|
32
|
+
if (error)
|
|
33
|
+
return reject(error);
|
|
34
|
+
if (!decoded || typeof decoded === 'string')
|
|
35
|
+
return reject(new Error('Token claims are invalid.'));
|
|
36
|
+
resolve(decoded);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.JwtTokenVerifier = JwtTokenVerifier;
|
|
42
|
+
function staticVerificationKey(key) {
|
|
43
|
+
return async () => key;
|
|
44
|
+
}
|