@siduri-x/core 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/dist/action-policy.d.ts +45 -0
- package/dist/action-policy.js +207 -0
- package/dist/action-policy.test.d.ts +1 -0
- package/dist/action-policy.test.js +157 -0
- package/dist/action.d.ts +70 -0
- package/dist/action.js +2 -0
- package/dist/architecture-boundary.test.d.ts +1 -0
- package/dist/architecture-boundary.test.js +116 -0
- package/dist/capability.d.ts +52 -0
- package/dist/capability.js +102 -0
- package/dist/capability.test.d.ts +1 -0
- package/dist/capability.test.js +180 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.js +92 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +109 -0
- package/dist/dispatcher.d.ts +14 -0
- package/dist/dispatcher.js +40 -0
- package/dist/dispatcher.test.d.ts +1 -0
- package/dist/dispatcher.test.js +60 -0
- package/dist/ear-types.d.ts +33 -0
- package/dist/ear-types.js +2 -0
- package/dist/evidence.d.ts +72 -0
- package/dist/evidence.js +45 -0
- package/dist/evidence.test.d.ts +1 -0
- package/dist/evidence.test.js +101 -0
- package/dist/experience.d.ts +56 -0
- package/dist/experience.js +78 -0
- package/dist/experience.test.d.ts +1 -0
- package/dist/experience.test.js +58 -0
- package/dist/gating.d.ts +45 -0
- package/dist/gating.js +189 -0
- package/dist/gating.test.d.ts +1 -0
- package/dist/gating.test.js +190 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +28 -0
- package/dist/runtime.d.ts +50 -0
- package/dist/runtime.js +404 -0
- package/dist/teaching.d.ts +15 -0
- package/dist/teaching.js +159 -0
- package/package.json +43 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Standard cryptographic implementations avoiding runtime ambient type dependencies
|
|
3
|
+
// using node's built-in crypto module dynamically or via require for universal CommonJS compatibility
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.InMemoryActionStore = void 0;
|
|
6
|
+
exports.canonicalizeJson = canonicalizeJson;
|
|
7
|
+
exports.computeParametersHash = computeParametersHash;
|
|
8
|
+
exports.signCapabilityPayload = signCapabilityPayload;
|
|
9
|
+
exports.verifyCapabilitySignature = verifyCapabilitySignature;
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
class InMemoryActionStore {
|
|
13
|
+
executions = new Map();
|
|
14
|
+
auditLog = [];
|
|
15
|
+
lastAuditHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
16
|
+
async reserveExecution(record) {
|
|
17
|
+
if (this.executions.has(record.executionId)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
this.executions.set(record.executionId, { ...record });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
async updateExecution(record) {
|
|
24
|
+
this.executions.set(record.executionId, { ...record });
|
|
25
|
+
}
|
|
26
|
+
async getExecution(executionId) {
|
|
27
|
+
const rec = this.executions.get(executionId);
|
|
28
|
+
return rec ? { ...rec } : undefined;
|
|
29
|
+
}
|
|
30
|
+
async appendAudit(event) {
|
|
31
|
+
// Tamper-evident hash chaining: SHA-256(prevHash + ":" + canonicalEvent)
|
|
32
|
+
const eventPayload = {
|
|
33
|
+
executionId: event.executionId,
|
|
34
|
+
actionId: event.actionId,
|
|
35
|
+
toolName: event.toolName,
|
|
36
|
+
lifecycle: event.lifecycle,
|
|
37
|
+
parametersHash: event.parametersHash,
|
|
38
|
+
timestamp: event.timestamp,
|
|
39
|
+
};
|
|
40
|
+
const canonical = canonicalizeJson(eventPayload);
|
|
41
|
+
const eventHash = crypto
|
|
42
|
+
.createHash('sha256')
|
|
43
|
+
.update(`${this.lastAuditHash}:${canonical}`, 'utf8')
|
|
44
|
+
.digest('hex');
|
|
45
|
+
this.lastAuditHash = eventHash;
|
|
46
|
+
const recordWithHash = {
|
|
47
|
+
...event,
|
|
48
|
+
resultHash: event.resultHash || eventHash,
|
|
49
|
+
};
|
|
50
|
+
this.auditLog.push(recordWithHash);
|
|
51
|
+
}
|
|
52
|
+
async getAuditLog(executionId) {
|
|
53
|
+
if (executionId) {
|
|
54
|
+
return this.auditLog.filter((e) => e.executionId === executionId).map((e) => ({ ...e }));
|
|
55
|
+
}
|
|
56
|
+
return this.auditLog.map((e) => ({ ...e }));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.InMemoryActionStore = InMemoryActionStore;
|
|
60
|
+
function canonicalizeJson(obj) {
|
|
61
|
+
if (obj === null || typeof obj !== 'object') {
|
|
62
|
+
return JSON.stringify(obj);
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(obj)) {
|
|
65
|
+
return `[${obj.map(canonicalizeJson).join(',')}]`;
|
|
66
|
+
}
|
|
67
|
+
const keys = Object.keys(obj).sort();
|
|
68
|
+
const pairs = keys.map((k) => `"${k}":${canonicalizeJson(obj[k])}`);
|
|
69
|
+
return `{${pairs.join(',')}}`;
|
|
70
|
+
}
|
|
71
|
+
function computeParametersHash(params) {
|
|
72
|
+
const canonical = canonicalizeJson(params || {});
|
|
73
|
+
return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
74
|
+
}
|
|
75
|
+
function signCapabilityPayload(payload, secretKey = 'siduri_y_action_policy_secret') {
|
|
76
|
+
const canonicalStr = canonicalizeJson(payload);
|
|
77
|
+
return crypto.createHmac('sha256', secretKey).update(canonicalStr, 'utf8').digest('hex');
|
|
78
|
+
}
|
|
79
|
+
function verifyCapabilitySignature(capability, secretKey = 'siduri_y_action_policy_secret') {
|
|
80
|
+
if (!capability || capability.allowed !== true || typeof capability.signature !== 'string') {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const { signature, allowed, ...rest } = capability;
|
|
84
|
+
const canonicalStr = canonicalizeJson(rest);
|
|
85
|
+
const expectedSigHex = crypto.createHmac('sha256', secretKey).update(canonicalStr, 'utf8').digest('hex');
|
|
86
|
+
// Constant-time comparison to prevent timing attacks
|
|
87
|
+
try {
|
|
88
|
+
const sigBuffer = globalThis.Buffer
|
|
89
|
+
? globalThis.Buffer.from(signature, 'hex')
|
|
90
|
+
: new Uint8Array(signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []);
|
|
91
|
+
const expectedBuffer = globalThis.Buffer
|
|
92
|
+
? globalThis.Buffer.from(expectedSigHex, 'hex')
|
|
93
|
+
: new Uint8Array(expectedSigHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []);
|
|
94
|
+
if (sigBuffer.length !== expectedBuffer.length || sigBuffer.length === 0) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const capability_1 = require("./capability");
|
|
6
|
+
const action_policy_1 = require("./action-policy");
|
|
7
|
+
describe('AuthorizationCapability Cryptographic & Tamper Review', () => {
|
|
8
|
+
const secretKey = 'prod_secret_signing_key';
|
|
9
|
+
let engine;
|
|
10
|
+
let sampleContext;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
engine = new action_policy_1.ActionPolicyEngine({
|
|
13
|
+
secretKey,
|
|
14
|
+
defaultRiskLevel: 'LOW',
|
|
15
|
+
});
|
|
16
|
+
sampleContext = {
|
|
17
|
+
companionId: 'comp-alpha',
|
|
18
|
+
actor: {
|
|
19
|
+
actorId: 'user-77',
|
|
20
|
+
sessionId: 'sess-88',
|
|
21
|
+
authorizationRole: 'operator',
|
|
22
|
+
capabilities: ['tool:send_email'],
|
|
23
|
+
authenticated: true,
|
|
24
|
+
},
|
|
25
|
+
conversation: {
|
|
26
|
+
channel: 'direct',
|
|
27
|
+
audienceId: 'aud-alpha',
|
|
28
|
+
correlationId: 'corr-999',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
engine.registerToolDefinition({
|
|
32
|
+
name: 'send_email',
|
|
33
|
+
providerId: 'comm',
|
|
34
|
+
description: 'Send email',
|
|
35
|
+
inputSchema: {},
|
|
36
|
+
riskLevel: 'LOW',
|
|
37
|
+
requiredCapabilities: ['tool:send_email'],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('Field-Level Tampering Invalidation (HMAC-SHA-256)', () => {
|
|
41
|
+
let legitCapability;
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
const { capability } = await engine.evaluateAction({
|
|
44
|
+
actionId: 'act-email-1',
|
|
45
|
+
toolName: 'comm/send_email',
|
|
46
|
+
parameters: { to: 'alice@example.com', subject: 'Hello' },
|
|
47
|
+
context: sampleContext,
|
|
48
|
+
});
|
|
49
|
+
expect(capability).toBeDefined();
|
|
50
|
+
legitCapability = capability;
|
|
51
|
+
});
|
|
52
|
+
it('verifies untouched authentic capability', () => {
|
|
53
|
+
expect((0, capability_1.verifyCapabilitySignature)(legitCapability, secretKey)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('invalidates signature if executionId is modified', () => {
|
|
56
|
+
const tampered = { ...legitCapability, executionId: 'exec-tampered-999' };
|
|
57
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('invalidates signature if actionId is modified', () => {
|
|
60
|
+
const tampered = { ...legitCapability, actionId: 'act-tampered-999' };
|
|
61
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('invalidates signature if toolName is modified', () => {
|
|
64
|
+
const tampered = { ...legitCapability, toolName: 'admin/delete_all' };
|
|
65
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it('invalidates signature if providerId is modified', () => {
|
|
68
|
+
const tampered = { ...legitCapability, providerId: 'untrusted_plugin' };
|
|
69
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it('invalidates signature if parametersHash is modified', () => {
|
|
72
|
+
const tampered = { ...legitCapability, parametersHash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' };
|
|
73
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('invalidates signature if actorId is modified', () => {
|
|
76
|
+
const tampered = { ...legitCapability, actorId: 'attacker-root' };
|
|
77
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
it('invalidates signature if sessionId is modified', () => {
|
|
80
|
+
const tampered = { ...legitCapability, sessionId: 'hijacked-session' };
|
|
81
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
it('invalidates signature if channel is modified', () => {
|
|
84
|
+
const tampered = { ...legitCapability, channel: 'public' };
|
|
85
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it('invalidates signature if correlationId is modified', () => {
|
|
88
|
+
const tampered = { ...legitCapability, correlationId: 'corr-spoofed' };
|
|
89
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it('invalidates signature if expiresAt is modified to extend lifetime', () => {
|
|
92
|
+
const tampered = { ...legitCapability, expiresAt: new Date(Date.now() + 86400000).toISOString() };
|
|
93
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('invalidates signature if issuedAt is modified', () => {
|
|
96
|
+
const tampered = { ...legitCapability, issuedAt: new Date(Date.now() - 100000).toISOString() };
|
|
97
|
+
expect((0, capability_1.verifyCapabilitySignature)(tampered, secretKey)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
it('rejects verification if secretKey is mismatched', () => {
|
|
100
|
+
expect((0, capability_1.verifyCapabilitySignature)(legitCapability, 'wrong_secret_key')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it('handles malformed, truncated, or non-hex signatures safely without crashing', () => {
|
|
103
|
+
const malformed1 = { ...legitCapability, signature: 'not_a_hex_string' };
|
|
104
|
+
expect((0, capability_1.verifyCapabilitySignature)(malformed1, secretKey)).toBe(false);
|
|
105
|
+
const malformed2 = { ...legitCapability, signature: 'deadbeef' };
|
|
106
|
+
expect((0, capability_1.verifyCapabilitySignature)(malformed2, secretKey)).toBe(false);
|
|
107
|
+
const malformed3 = { ...legitCapability, signature: '' };
|
|
108
|
+
expect((0, capability_1.verifyCapabilitySignature)(malformed3, secretKey)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('Deterministic Canonicalization', () => {
|
|
112
|
+
it('produces identical canonical JSON strings regardless of key insertion order', () => {
|
|
113
|
+
const objA = { b: 2, a: 1, c: { z: 26, y: 25 } };
|
|
114
|
+
const objB = { a: 1, c: { y: 25, z: 26 }, b: 2 };
|
|
115
|
+
expect((0, capability_1.canonicalizeJson)(objA)).toBe((0, capability_1.canonicalizeJson)(objB));
|
|
116
|
+
expect((0, capability_1.computeParametersHash)(objA)).toBe((0, capability_1.computeParametersHash)(objB));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('Audit Trail SHA-256 Hash Chaining and Mutation Detection', () => {
|
|
120
|
+
it('detects tampering or mutation in previous audit events in the chain', async () => {
|
|
121
|
+
const store = new capability_1.InMemoryActionStore();
|
|
122
|
+
const policyEngine = new action_policy_1.ActionPolicyEngine({ store, secretKey });
|
|
123
|
+
policyEngine.registerToolDefinition({
|
|
124
|
+
name: 'send_email',
|
|
125
|
+
providerId: 'comm',
|
|
126
|
+
description: 'Send email',
|
|
127
|
+
inputSchema: {},
|
|
128
|
+
riskLevel: 'LOW',
|
|
129
|
+
});
|
|
130
|
+
// Event 1
|
|
131
|
+
await policyEngine.evaluateAction({
|
|
132
|
+
actionId: 'act-1',
|
|
133
|
+
toolName: 'comm/send_email',
|
|
134
|
+
parameters: { step: 1 },
|
|
135
|
+
context: sampleContext,
|
|
136
|
+
});
|
|
137
|
+
// Event 2
|
|
138
|
+
await policyEngine.evaluateAction({
|
|
139
|
+
actionId: 'act-2',
|
|
140
|
+
toolName: 'comm/send_email',
|
|
141
|
+
parameters: { step: 2 },
|
|
142
|
+
context: sampleContext,
|
|
143
|
+
});
|
|
144
|
+
const auditTrail = await store.getAuditLog();
|
|
145
|
+
expect(auditTrail.length).toBe(2);
|
|
146
|
+
const event1 = auditTrail[0];
|
|
147
|
+
const event2 = auditTrail[1];
|
|
148
|
+
// Recompute expected hash chain using standard SHA-256
|
|
149
|
+
const initialPrevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
150
|
+
const canonical1 = (0, capability_1.canonicalizeJson)({
|
|
151
|
+
executionId: event1.executionId,
|
|
152
|
+
actionId: event1.actionId,
|
|
153
|
+
toolName: event1.toolName,
|
|
154
|
+
lifecycle: event1.lifecycle,
|
|
155
|
+
parametersHash: event1.parametersHash,
|
|
156
|
+
timestamp: event1.timestamp,
|
|
157
|
+
});
|
|
158
|
+
const expectedHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${canonical1}`, 'utf8').digest('hex');
|
|
159
|
+
expect(event1.resultHash).toBe(expectedHash1);
|
|
160
|
+
const canonical2 = (0, capability_1.canonicalizeJson)({
|
|
161
|
+
executionId: event2.executionId,
|
|
162
|
+
actionId: event2.actionId,
|
|
163
|
+
toolName: event2.toolName,
|
|
164
|
+
lifecycle: event2.lifecycle,
|
|
165
|
+
parametersHash: event2.parametersHash,
|
|
166
|
+
timestamp: event2.timestamp,
|
|
167
|
+
});
|
|
168
|
+
const expectedHash2 = crypto.createHash('sha256').update(`${expectedHash1}:${canonical2}`, 'utf8').digest('hex');
|
|
169
|
+
expect(event2.resultHash).toBe(expectedHash2);
|
|
170
|
+
// If an attacker altered event 1 retrospectively, the hash chain breaks for event 2
|
|
171
|
+
const tamperedCanonical1 = (0, capability_1.canonicalizeJson)({
|
|
172
|
+
...JSON.parse(canonical1),
|
|
173
|
+
actionId: 'act-tampered-1',
|
|
174
|
+
});
|
|
175
|
+
const tamperedHash1 = crypto.createHash('sha256').update(`${initialPrevHash}:${tamperedCanonical1}`, 'utf8').digest('hex');
|
|
176
|
+
const brokenHash2 = crypto.createHash('sha256').update(`${tamperedHash1}:${canonical2}`, 'utf8').digest('hex');
|
|
177
|
+
expect(brokenHash2).not.toBe(event2.resultHash);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type AuthorizationRole = 'viewer' | 'operator' | 'administrator';
|
|
2
|
+
export type Channel = 'public' | 'direct' | 'private' | 'operator';
|
|
3
|
+
export interface ActorContext {
|
|
4
|
+
actorId: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
authorizationRole: AuthorizationRole;
|
|
7
|
+
capabilities: string[];
|
|
8
|
+
authenticated: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ConversationContext {
|
|
11
|
+
channel: Channel;
|
|
12
|
+
audienceId: string;
|
|
13
|
+
isLive?: boolean;
|
|
14
|
+
correlationId: string;
|
|
15
|
+
}
|
|
16
|
+
export type SubjectKind = 'actor' | 'companion' | 'configured';
|
|
17
|
+
export interface SubjectRef {
|
|
18
|
+
subjectId: string;
|
|
19
|
+
kind: SubjectKind;
|
|
20
|
+
ownerActorId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RequestContext {
|
|
23
|
+
companionId: string;
|
|
24
|
+
actor: ActorContext;
|
|
25
|
+
conversation: ConversationContext;
|
|
26
|
+
subject?: SubjectRef;
|
|
27
|
+
}
|
|
28
|
+
export type DiagnosticCode = 'audience_defaulted_by_public_policy' | 'legacy_role_mapped_to_authorization' | 'anonymous_session_generated' | 'companion_default_mapped_for_bootstrap' | 'actor_scoped_subject_mapped' | 'legacy_primary_user_quarantined';
|
|
29
|
+
export type ContextErrorCode = 'MISSING_CONTEXT' | 'INVALID_CONTEXT' | 'AMBIGUOUS_CONTEXT' | 'FORBIDDEN_CONTEXT' | 'LEGACY_PERSONAL_AUDIENCE' | 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY';
|
|
30
|
+
export interface ContextError {
|
|
31
|
+
code: ContextErrorCode;
|
|
32
|
+
message?: string;
|
|
33
|
+
fields?: string[];
|
|
34
|
+
field?: string;
|
|
35
|
+
conflicts?: string[];
|
|
36
|
+
correlationId?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface RequestContextValidationResult {
|
|
39
|
+
accepted: boolean;
|
|
40
|
+
context?: RequestContext;
|
|
41
|
+
diagnostics?: DiagnosticCode[];
|
|
42
|
+
error?: ContextError;
|
|
43
|
+
}
|
|
44
|
+
export declare function isValidAuthorizationRole(role: unknown): role is AuthorizationRole;
|
|
45
|
+
export declare function isValidChannel(channel: unknown): channel is Channel;
|
|
46
|
+
export declare function isValidSubjectKind(kind: unknown): kind is SubjectKind;
|
|
47
|
+
export declare function validateRequestContext(context: unknown): RequestContextValidationResult;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isValidAuthorizationRole = isValidAuthorizationRole;
|
|
4
|
+
exports.isValidChannel = isValidChannel;
|
|
5
|
+
exports.isValidSubjectKind = isValidSubjectKind;
|
|
6
|
+
exports.validateRequestContext = validateRequestContext;
|
|
7
|
+
function isValidAuthorizationRole(role) {
|
|
8
|
+
return role === 'viewer' || role === 'operator' || role === 'administrator';
|
|
9
|
+
}
|
|
10
|
+
function isValidChannel(channel) {
|
|
11
|
+
return channel === 'public' || channel === 'direct' || channel === 'private' || channel === 'operator';
|
|
12
|
+
}
|
|
13
|
+
function isValidSubjectKind(kind) {
|
|
14
|
+
return kind === 'actor' || kind === 'companion' || kind === 'configured';
|
|
15
|
+
}
|
|
16
|
+
function validateRequestContext(context) {
|
|
17
|
+
if (!context || typeof context !== 'object') {
|
|
18
|
+
return {
|
|
19
|
+
accepted: false,
|
|
20
|
+
error: {
|
|
21
|
+
code: 'MISSING_CONTEXT',
|
|
22
|
+
fields: ['context'],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const ctx = context;
|
|
27
|
+
const missingFields = [];
|
|
28
|
+
if (!ctx.companionId || typeof ctx.companionId !== 'string' || ctx.companionId.trim() === '') {
|
|
29
|
+
missingFields.push('companionId');
|
|
30
|
+
}
|
|
31
|
+
if (!ctx.actor || typeof ctx.actor !== 'object') {
|
|
32
|
+
missingFields.push('actor');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
if (!ctx.actor.actorId || typeof ctx.actor.actorId !== 'string' || ctx.actor.actorId.trim() === '') {
|
|
36
|
+
missingFields.push('actor.actorId');
|
|
37
|
+
}
|
|
38
|
+
if (!ctx.actor.sessionId || typeof ctx.actor.sessionId !== 'string' || ctx.actor.sessionId.trim() === '') {
|
|
39
|
+
missingFields.push('actor.sessionId');
|
|
40
|
+
}
|
|
41
|
+
if (!isValidAuthorizationRole(ctx.actor.authorizationRole)) {
|
|
42
|
+
missingFields.push('actor.authorizationRole');
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(ctx.actor.capabilities)) {
|
|
45
|
+
missingFields.push('actor.capabilities');
|
|
46
|
+
}
|
|
47
|
+
if (typeof ctx.actor.authenticated !== 'boolean') {
|
|
48
|
+
missingFields.push('actor.authenticated');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!ctx.conversation || typeof ctx.conversation !== 'object') {
|
|
52
|
+
missingFields.push('conversation');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (!isValidChannel(ctx.conversation.channel)) {
|
|
56
|
+
missingFields.push('conversation.channel');
|
|
57
|
+
}
|
|
58
|
+
if (!ctx.conversation.audienceId || typeof ctx.conversation.audienceId !== 'string' || ctx.conversation.audienceId.trim() === '') {
|
|
59
|
+
missingFields.push('conversation.audienceId');
|
|
60
|
+
}
|
|
61
|
+
if (!ctx.conversation.correlationId || typeof ctx.conversation.correlationId !== 'string' || ctx.conversation.correlationId.trim() === '') {
|
|
62
|
+
missingFields.push('conversation.correlationId');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (ctx.subject !== undefined) {
|
|
66
|
+
if (!ctx.subject || typeof ctx.subject !== 'object') {
|
|
67
|
+
missingFields.push('subject');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!ctx.subject.subjectId || typeof ctx.subject.subjectId !== 'string' || ctx.subject.subjectId.trim() === '') {
|
|
71
|
+
missingFields.push('subject.subjectId');
|
|
72
|
+
}
|
|
73
|
+
if (!isValidSubjectKind(ctx.subject.kind)) {
|
|
74
|
+
missingFields.push('subject.kind');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (missingFields.length > 0) {
|
|
79
|
+
return {
|
|
80
|
+
accepted: false,
|
|
81
|
+
error: {
|
|
82
|
+
code: 'MISSING_CONTEXT',
|
|
83
|
+
fields: missingFields,
|
|
84
|
+
correlationId: ctx.conversation?.correlationId,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
accepted: true,
|
|
90
|
+
context: ctx,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const context_1 = require("./context");
|
|
4
|
+
describe('Core Context Contract (P1)', () => {
|
|
5
|
+
const validContext = {
|
|
6
|
+
companionId: 'companion-a',
|
|
7
|
+
actor: {
|
|
8
|
+
actorId: 'actor-a',
|
|
9
|
+
sessionId: 'session-a',
|
|
10
|
+
authorizationRole: 'viewer',
|
|
11
|
+
capabilities: ['chat:public'],
|
|
12
|
+
authenticated: false,
|
|
13
|
+
},
|
|
14
|
+
conversation: {
|
|
15
|
+
channel: 'public',
|
|
16
|
+
audienceId: 'audience-public',
|
|
17
|
+
correlationId: 'corr-a',
|
|
18
|
+
},
|
|
19
|
+
subject: {
|
|
20
|
+
subjectId: 'actor:actor-a',
|
|
21
|
+
kind: 'actor',
|
|
22
|
+
ownerActorId: 'actor-a',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
test('validates a correct neutral RequestContext', () => {
|
|
26
|
+
const result = (0, context_1.validateRequestContext)(validContext);
|
|
27
|
+
expect(result.accepted).toBe(true);
|
|
28
|
+
expect(result.context).toEqual(validContext);
|
|
29
|
+
expect(result.error).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
test('validates a correct RequestContext without subject', () => {
|
|
32
|
+
const { subject, ...contextWithoutSubject } = validContext;
|
|
33
|
+
const result = (0, context_1.validateRequestContext)(contextWithoutSubject);
|
|
34
|
+
expect(result.accepted).toBe(true);
|
|
35
|
+
expect(result.context?.subject).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
test('rejects missing root context or non-object', () => {
|
|
38
|
+
const result = (0, context_1.validateRequestContext)(null);
|
|
39
|
+
expect(result.accepted).toBe(false);
|
|
40
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
41
|
+
expect(result.error?.fields).toContain('context');
|
|
42
|
+
});
|
|
43
|
+
test('rejects missing companionId, actor, or conversation', () => {
|
|
44
|
+
const result = (0, context_1.validateRequestContext)({});
|
|
45
|
+
expect(result.accepted).toBe(false);
|
|
46
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
47
|
+
expect(result.error?.fields).toEqual(expect.arrayContaining(['companionId', 'actor', 'conversation']));
|
|
48
|
+
});
|
|
49
|
+
test('validates authorization role constraints', () => {
|
|
50
|
+
expect((0, context_1.isValidAuthorizationRole)('viewer')).toBe(true);
|
|
51
|
+
expect((0, context_1.isValidAuthorizationRole)('operator')).toBe(true);
|
|
52
|
+
expect((0, context_1.isValidAuthorizationRole)('administrator')).toBe(true);
|
|
53
|
+
expect((0, context_1.isValidAuthorizationRole)('owner')).toBe(false);
|
|
54
|
+
expect((0, context_1.isValidAuthorizationRole)('user')).toBe(false);
|
|
55
|
+
expect((0, context_1.isValidAuthorizationRole)('MASTER')).toBe(false);
|
|
56
|
+
const invalidRoleCtx = {
|
|
57
|
+
...validContext,
|
|
58
|
+
actor: { ...validContext.actor, authorizationRole: 'invalid_role' },
|
|
59
|
+
};
|
|
60
|
+
const result = (0, context_1.validateRequestContext)(invalidRoleCtx);
|
|
61
|
+
expect(result.accepted).toBe(false);
|
|
62
|
+
expect(result.error?.fields).toContain('actor.authorizationRole');
|
|
63
|
+
});
|
|
64
|
+
test('validates channel constraints', () => {
|
|
65
|
+
expect((0, context_1.isValidChannel)('public')).toBe(true);
|
|
66
|
+
expect((0, context_1.isValidChannel)('direct')).toBe(true);
|
|
67
|
+
expect((0, context_1.isValidChannel)('private')).toBe(true);
|
|
68
|
+
expect((0, context_1.isValidChannel)('operator')).toBe(true);
|
|
69
|
+
expect((0, context_1.isValidChannel)('chat')).toBe(false);
|
|
70
|
+
expect((0, context_1.isValidChannel)('MASTER_PRIVATE')).toBe(false);
|
|
71
|
+
const invalidChannelCtx = {
|
|
72
|
+
...validContext,
|
|
73
|
+
conversation: { ...validContext.conversation, channel: 'invalid_channel' },
|
|
74
|
+
};
|
|
75
|
+
const result = (0, context_1.validateRequestContext)(invalidChannelCtx);
|
|
76
|
+
expect(result.accepted).toBe(false);
|
|
77
|
+
expect(result.error?.fields).toContain('conversation.channel');
|
|
78
|
+
});
|
|
79
|
+
test('validates subject kinds and constraints', () => {
|
|
80
|
+
expect((0, context_1.isValidSubjectKind)('actor')).toBe(true);
|
|
81
|
+
expect((0, context_1.isValidSubjectKind)('companion')).toBe(true);
|
|
82
|
+
expect((0, context_1.isValidSubjectKind)('configured')).toBe(true);
|
|
83
|
+
expect((0, context_1.isValidSubjectKind)('user')).toBe(false);
|
|
84
|
+
const invalidSubjectCtx = {
|
|
85
|
+
...validContext,
|
|
86
|
+
subject: { subjectId: 'subject-1', kind: 'invalid_kind' },
|
|
87
|
+
};
|
|
88
|
+
const result = (0, context_1.validateRequestContext)(invalidSubjectCtx);
|
|
89
|
+
expect(result.accepted).toBe(false);
|
|
90
|
+
expect(result.error?.fields).toContain('subject.kind');
|
|
91
|
+
});
|
|
92
|
+
test('rejects missing correlationId and preserves correlationId in error if present', () => {
|
|
93
|
+
const missingCorr = {
|
|
94
|
+
...validContext,
|
|
95
|
+
conversation: { ...validContext.conversation, correlationId: '' },
|
|
96
|
+
};
|
|
97
|
+
const result = (0, context_1.validateRequestContext)(missingCorr);
|
|
98
|
+
expect(result.accepted).toBe(false);
|
|
99
|
+
expect(result.error?.fields).toContain('conversation.correlationId');
|
|
100
|
+
const missingActorId = {
|
|
101
|
+
...validContext,
|
|
102
|
+
actor: { ...validContext.actor, actorId: '' },
|
|
103
|
+
};
|
|
104
|
+
const result2 = (0, context_1.validateRequestContext)(missingActorId);
|
|
105
|
+
expect(result2.accepted).toBe(false);
|
|
106
|
+
expect(result2.error?.fields).toContain('actor.actorId');
|
|
107
|
+
expect(result2.error?.correlationId).toBe('corr-a');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ExperienceAdapter, ExperienceEvent, ExperienceAdapterResult } from './experience';
|
|
2
|
+
export interface DispatchSummary {
|
|
3
|
+
dispatched: boolean;
|
|
4
|
+
eventResults: {
|
|
5
|
+
event: ExperienceEvent;
|
|
6
|
+
result: ExperienceAdapterResult;
|
|
7
|
+
}[];
|
|
8
|
+
}
|
|
9
|
+
export declare class ExperienceDispatcher {
|
|
10
|
+
private readonly adapters;
|
|
11
|
+
private readonly dispatchedEventIds;
|
|
12
|
+
registerAdapter(adapter: ExperienceAdapter): void;
|
|
13
|
+
dispatchEvents(events: ExperienceEvent[]): Promise<DispatchSummary>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExperienceDispatcher = void 0;
|
|
4
|
+
class ExperienceDispatcher {
|
|
5
|
+
adapters = [];
|
|
6
|
+
dispatchedEventIds = new Set();
|
|
7
|
+
registerAdapter(adapter) {
|
|
8
|
+
this.adapters.push(adapter);
|
|
9
|
+
}
|
|
10
|
+
async dispatchEvents(events) {
|
|
11
|
+
const eventResults = [];
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
// Replay / duplicate dispatch protection: each eventId is dispatched only once
|
|
14
|
+
if (this.dispatchedEventIds.has(event.eventId)) {
|
|
15
|
+
eventResults.push({
|
|
16
|
+
event,
|
|
17
|
+
result: {
|
|
18
|
+
accepted: false,
|
|
19
|
+
eventId: event.eventId,
|
|
20
|
+
lifecycle: 'FAILED',
|
|
21
|
+
error: 'Duplicate event ID already dispatched',
|
|
22
|
+
reason: 'DUPLICATE_EVENT_DISPATCH',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
this.dispatchedEventIds.add(event.eventId);
|
|
28
|
+
const matchingAdapters = this.adapters.filter((a) => a.kind === event.kind);
|
|
29
|
+
for (const adapter of matchingAdapters) {
|
|
30
|
+
const result = await adapter.handleEvent(event);
|
|
31
|
+
eventResults.push({ event, result });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
dispatched: eventResults.some((r) => r.result.accepted),
|
|
36
|
+
eventResults,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.ExperienceDispatcher = ExperienceDispatcher;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const dispatcher_1 = require("./dispatcher");
|
|
4
|
+
const experience_1 = require("./experience");
|
|
5
|
+
describe('T5 ExperienceDispatcher Contract Suite', () => {
|
|
6
|
+
let dispatcher;
|
|
7
|
+
let mockVoiceAdapter;
|
|
8
|
+
let mockAvatarAdapter;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dispatcher = new dispatcher_1.ExperienceDispatcher();
|
|
11
|
+
mockVoiceAdapter = {
|
|
12
|
+
kind: 'voice',
|
|
13
|
+
handleEvent: jest.fn().mockImplementation(async (event) => ({
|
|
14
|
+
accepted: true,
|
|
15
|
+
eventId: event.eventId,
|
|
16
|
+
lifecycle: 'STARTED',
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
mockAvatarAdapter = {
|
|
20
|
+
kind: 'avatar',
|
|
21
|
+
handleEvent: jest.fn().mockImplementation(async (event) => ({
|
|
22
|
+
accepted: true,
|
|
23
|
+
eventId: event.eventId,
|
|
24
|
+
lifecycle: 'STARTED',
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
dispatcher.registerAdapter(mockVoiceAdapter);
|
|
28
|
+
dispatcher.registerAdapter(mockAvatarAdapter);
|
|
29
|
+
});
|
|
30
|
+
test('dispatches experience events to matching adapters', async () => {
|
|
31
|
+
const events = (0, experience_1.createExperienceEvents)({
|
|
32
|
+
responseId: 'resp-1',
|
|
33
|
+
companionId: 'companion-a',
|
|
34
|
+
correlationId: 'corr-1',
|
|
35
|
+
channel: 'public',
|
|
36
|
+
audienceId: 'audience-public',
|
|
37
|
+
speech: 'Hello dispatch',
|
|
38
|
+
language: 'en',
|
|
39
|
+
});
|
|
40
|
+
const summary = await dispatcher.dispatchEvents(events);
|
|
41
|
+
expect(summary.dispatched).toBe(true);
|
|
42
|
+
expect(summary.eventResults.length).toBe(2);
|
|
43
|
+
expect(mockVoiceAdapter.handleEvent).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(mockAvatarAdapter.handleEvent).toHaveBeenCalledTimes(1);
|
|
45
|
+
});
|
|
46
|
+
test('does not dispatch when no matching adapters registered', async () => {
|
|
47
|
+
const emptyDispatcher = new dispatcher_1.ExperienceDispatcher();
|
|
48
|
+
const events = (0, experience_1.createExperienceEvents)({
|
|
49
|
+
responseId: 'resp-1',
|
|
50
|
+
companionId: 'companion-a',
|
|
51
|
+
correlationId: 'corr-1',
|
|
52
|
+
channel: 'public',
|
|
53
|
+
audienceId: 'audience-public',
|
|
54
|
+
speech: 'Hello empty',
|
|
55
|
+
});
|
|
56
|
+
const summary = await emptyDispatcher.dispatchEvents(events);
|
|
57
|
+
expect(summary.dispatched).toBe(false);
|
|
58
|
+
expect(summary.eventResults.length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|