@cratis/chronicle 6.1.1 → 6.3.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.
Files changed (132) hide show
  1. package/artifacts/DefaultClientArtifactsProvider.ts +5 -0
  2. package/artifacts/IClientArtifactsProvider.ts +3 -0
  3. package/confidentiality/EncryptedNotSupportedOnEventSourceId.ts +21 -0
  4. package/confidentiality/EncryptionScope.ts +34 -0
  5. package/confidentiality/PIIAndEncryptedCombinedNotSupported.ts +29 -0
  6. package/confidentiality/SecurityMetadata.ts +22 -0
  7. package/confidentiality/SecurityMetadataResolver.ts +54 -0
  8. package/confidentiality/SecurityMetadataType.ts +56 -0
  9. package/confidentiality/encrypted.spec.ts +105 -0
  10. package/confidentiality/encrypted.ts +143 -0
  11. package/confidentiality/index.ts +10 -0
  12. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts +2 -0
  13. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
  14. package/dist/artifacts/DefaultClientArtifactsProvider.js +4 -0
  15. package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
  16. package/dist/artifacts/IClientArtifactsProvider.d.ts +2 -0
  17. package/dist/artifacts/IClientArtifactsProvider.d.ts.map +1 -1
  18. package/dist/confidentiality/EncryptedNotSupportedOnEventSourceId.d.ts +11 -0
  19. package/dist/confidentiality/EncryptedNotSupportedOnEventSourceId.d.ts.map +1 -0
  20. package/dist/confidentiality/EncryptedNotSupportedOnEventSourceId.js +19 -0
  21. package/dist/confidentiality/EncryptedNotSupportedOnEventSourceId.js.map +1 -0
  22. package/dist/confidentiality/EncryptionScope.d.ts +30 -0
  23. package/dist/confidentiality/EncryptionScope.d.ts.map +1 -0
  24. package/dist/confidentiality/EncryptionScope.js +33 -0
  25. package/dist/confidentiality/EncryptionScope.js.map +1 -0
  26. package/dist/confidentiality/PIIAndEncryptedCombinedNotSupported.d.ts +19 -0
  27. package/dist/confidentiality/PIIAndEncryptedCombinedNotSupported.d.ts.map +1 -0
  28. package/dist/confidentiality/PIIAndEncryptedCombinedNotSupported.js +27 -0
  29. package/dist/confidentiality/PIIAndEncryptedCombinedNotSupported.js.map +1 -0
  30. package/dist/confidentiality/SecurityMetadata.d.ts +18 -0
  31. package/dist/confidentiality/SecurityMetadata.d.ts.map +1 -0
  32. package/dist/confidentiality/SecurityMetadata.js +4 -0
  33. package/dist/confidentiality/SecurityMetadata.js.map +1 -0
  34. package/dist/confidentiality/SecurityMetadataResolver.d.ts +30 -0
  35. package/dist/confidentiality/SecurityMetadataResolver.d.ts.map +1 -0
  36. package/dist/confidentiality/SecurityMetadataResolver.js +48 -0
  37. package/dist/confidentiality/SecurityMetadataResolver.js.map +1 -0
  38. package/dist/confidentiality/SecurityMetadataType.d.ts +42 -0
  39. package/dist/confidentiality/SecurityMetadataType.d.ts.map +1 -0
  40. package/dist/confidentiality/SecurityMetadataType.js +50 -0
  41. package/dist/confidentiality/SecurityMetadataType.js.map +1 -0
  42. package/dist/confidentiality/encrypted.d.ts +72 -0
  43. package/dist/confidentiality/encrypted.d.ts.map +1 -0
  44. package/dist/confidentiality/encrypted.js +130 -0
  45. package/dist/confidentiality/encrypted.js.map +1 -0
  46. package/dist/confidentiality/index.d.ts +8 -0
  47. package/dist/confidentiality/index.d.ts.map +1 -0
  48. package/dist/confidentiality/index.js +9 -0
  49. package/dist/confidentiality/index.js.map +1 -0
  50. package/dist/index.d.ts +3 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts +14 -0
  55. package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts.map +1 -0
  56. package/dist/projections/GlobalHandlerPropertyNotOnVariant.js +19 -0
  57. package/dist/projections/GlobalHandlerPropertyNotOnVariant.js.map +1 -0
  58. package/dist/projections/Projections.d.ts +8 -0
  59. package/dist/projections/Projections.d.ts.map +1 -1
  60. package/dist/projections/Projections.js +81 -7
  61. package/dist/projections/Projections.js.map +1 -1
  62. package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts +11 -0
  63. package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts.map +1 -0
  64. package/dist/projections/VariantMustDeclareEntersOnEvent.js +16 -0
  65. package/dist/projections/VariantMustDeclareEntersOnEvent.js.map +1 -0
  66. package/dist/projections/VariantReclassifier.d.ts +63 -0
  67. package/dist/projections/VariantReclassifier.d.ts.map +1 -0
  68. package/dist/projections/VariantReclassifier.js +126 -0
  69. package/dist/projections/VariantReclassifier.js.map +1 -0
  70. package/dist/projections/declarative/IProjectionBuilderFor.d.ts +20 -0
  71. package/dist/projections/declarative/IProjectionBuilderFor.d.ts.map +1 -1
  72. package/dist/projections/declarative/ProjectionBuilderFor.d.ts +17 -1
  73. package/dist/projections/declarative/ProjectionBuilderFor.d.ts.map +1 -1
  74. package/dist/projections/declarative/ProjectionBuilderFor.js +34 -0
  75. package/dist/projections/declarative/ProjectionBuilderFor.js.map +1 -1
  76. package/dist/projections/index.d.ts +2 -0
  77. package/dist/projections/index.d.ts.map +1 -1
  78. package/dist/projections/index.js +2 -0
  79. package/dist/projections/index.js.map +1 -1
  80. package/dist/projections/modelBound/entersOn.d.ts +25 -0
  81. package/dist/projections/modelBound/entersOn.d.ts.map +1 -0
  82. package/dist/projections/modelBound/entersOn.js +28 -0
  83. package/dist/projections/modelBound/entersOn.js.map +1 -0
  84. package/dist/projections/modelBound/globalFor.d.ts +24 -0
  85. package/dist/projections/modelBound/globalFor.d.ts.map +1 -0
  86. package/dist/projections/modelBound/globalFor.js +31 -0
  87. package/dist/projections/modelBound/globalFor.js.map +1 -0
  88. package/dist/projections/modelBound/index.d.ts +6 -0
  89. package/dist/projections/modelBound/index.d.ts.map +1 -1
  90. package/dist/projections/modelBound/index.js +3 -0
  91. package/dist/projections/modelBound/index.js.map +1 -1
  92. package/dist/projections/modelBound/variantOf.d.ts +24 -0
  93. package/dist/projections/modelBound/variantOf.d.ts.map +1 -0
  94. package/dist/projections/modelBound/variantOf.js +27 -0
  95. package/dist/projections/modelBound/variantOf.js.map +1 -0
  96. package/dist/schemas/JsonSchema.d.ts +18 -0
  97. package/dist/schemas/JsonSchema.d.ts.map +1 -1
  98. package/dist/schemas/JsonSchemaGenerator.d.ts +41 -0
  99. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  100. package/dist/schemas/JsonSchemaGenerator.js +102 -9
  101. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  102. package/dist/tsconfig.tsbuildinfo +1 -1
  103. package/dist/types/DecoratorType.d.ts +3 -1
  104. package/dist/types/DecoratorType.d.ts.map +1 -1
  105. package/dist/types/DecoratorType.js +2 -0
  106. package/dist/types/DecoratorType.js.map +1 -1
  107. package/events/for_EventContext/when_receiving/with_kernel_metadata.spec.ts +1 -1
  108. package/index.ts +3 -0
  109. package/package.json +7 -2
  110. package/projections/GlobalHandlerPropertyNotOnVariant.ts +19 -0
  111. package/projections/Projections.childrenAndNested.spec.ts +2 -1
  112. package/projections/Projections.declarativeVariants.spec.ts +157 -0
  113. package/projections/Projections.modelBoundCompleteness.spec.ts +2 -1
  114. package/projections/Projections.operationalSurface.spec.ts +2 -1
  115. package/projections/Projections.spec.ts +2 -1
  116. package/projections/Projections.ts +98 -9
  117. package/projections/Projections.variants.spec.ts +253 -0
  118. package/projections/VariantMustDeclareEntersOnEvent.ts +16 -0
  119. package/projections/VariantReclassifier.ts +177 -0
  120. package/projections/declarative/IProjectionBuilderFor.ts +22 -0
  121. package/projections/declarative/ProjectionBuilderFor.ts +40 -1
  122. package/projections/index.ts +2 -0
  123. package/projections/modelBound/entersOn.ts +39 -0
  124. package/projections/modelBound/globalFor.ts +41 -0
  125. package/projections/modelBound/index.ts +6 -0
  126. package/projections/modelBound/variantOf.ts +38 -0
  127. package/readModels/ReadModels.spec.ts +2 -1
  128. package/reducers/Reducers.spec.ts +2 -1
  129. package/schemas/JsonSchema.ts +20 -0
  130. package/schemas/JsonSchemaGenerator.confidentiality.spec.ts +194 -0
  131. package/schemas/JsonSchemaGenerator.ts +115 -10
  132. package/types/DecoratorType.ts +4 -1
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+
6
+ /** Metadata stored by the variantOf class decorator. */
7
+ export interface VariantOfMetadata {
8
+ /** The type anchoring the logical identity every variant of this group shares. */
9
+ readonly identity: Function;
10
+ /** The property name on this variant used as its own key, and as the correlation property for reclassified joins. */
11
+ readonly key: string;
12
+ }
13
+
14
+ const METADATA_KEY = 'chronicle:projection:variantOf';
15
+
16
+ /**
17
+ * Class decorator that declares a read model to be one of several mutually exclusive
18
+ * representations of the same logical entity. Entering one variant removes the entity
19
+ * from every sibling variant of the same identity.
20
+ * @param identity - The type anchoring the logical identity shared by every variant. Does not need to be a read model itself.
21
+ * @param key - The property name on this variant used as its own key.
22
+ * @returns A class decorator.
23
+ */
24
+ export function variantOf(identity: Function, key: string): ClassDecorator {
25
+ return (target: object) => {
26
+ const metadata: VariantOfMetadata = { identity, key };
27
+ Reflect.defineMetadata(METADATA_KEY, metadata, target);
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Retrieves variantOf metadata stored on the given class constructor.
33
+ * @param target - The class constructor.
34
+ * @returns The variantOf metadata, or undefined if the class is not a variant.
35
+ */
36
+ export function getVariantOfMetadata(target: Function): VariantOfMetadata | undefined {
37
+ return Reflect.getMetadata(METADATA_KEY, target);
38
+ }
@@ -33,7 +33,8 @@ function createReadModels(readModelType: Constructor, releaseResponse: Record<st
33
33
  constraints: [],
34
34
  projections: [],
35
35
  webhooks: [],
36
- eventTypeMigrations: []
36
+ eventTypeMigrations: [],
37
+ globalForHandlers: []
37
38
  } as IClientArtifactsProvider;
38
39
 
39
40
  const readModels = new ReadModels('test-store', 'test-namespace', connection, clientArtifacts, 'default-sink');
@@ -62,7 +62,8 @@ function createArtifacts(reducers: Constructor[]): IClientArtifactsProvider {
62
62
  constraints: [],
63
63
  projections: [],
64
64
  webhooks: [],
65
- eventTypeMigrations: []
65
+ eventTypeMigrations: [],
66
+ globalForHandlers: []
66
67
  };
67
68
  }
68
69
 
@@ -16,6 +16,25 @@ export interface ComplianceSchemaMetadata {
16
16
  details: string;
17
17
  }
18
18
 
19
+ /**
20
+ * Represents a single security classification recorded on a schema node.
21
+ *
22
+ * This is the security counterpart to {@link ComplianceSchemaMetadata} - written under its own,
23
+ * separate `security` schema key rather than merged into `compliance`. See
24
+ * `Source/confidentiality/SecurityMetadataType.ts` for why.
25
+ */
26
+ export interface SecuritySchemaMetadata {
27
+ /**
28
+ * The type of security metadata (e.g. 'EncryptedSubject', 'EncryptedNamespace', 'EncryptedGlobal').
29
+ */
30
+ metadataType: string;
31
+
32
+ /**
33
+ * Any additional details - can be empty.
34
+ */
35
+ details: string;
36
+ }
37
+
19
38
  /**
20
39
  * Represents a JSON Schema object.
21
40
  */
@@ -31,4 +50,5 @@ export type JsonSchema = {
31
50
  additionalProperties?: boolean | JsonSchema;
32
51
  enum?: Array<string | number | boolean | null>;
33
52
  compliance?: ComplianceSchemaMetadata[];
53
+ security?: SecuritySchemaMetadata[];
34
54
  };
@@ -0,0 +1,194 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { ConceptAs, field } from '@cratis/fundamentals';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { pii } from '../compliance/pii';
8
+ import { encrypted } from '../confidentiality/encrypted';
9
+ import { EncryptionScope } from '../confidentiality/EncryptionScope';
10
+ import { PIIAndEncryptedCombinedNotSupported } from '../confidentiality/PIIAndEncryptedCombinedNotSupported';
11
+ import { getReadModelMetadata, readModel } from '../readModels/readModel';
12
+ import { JsonSchema } from './JsonSchema';
13
+
14
+ // See JsonSchemaGenerator.spec.ts for why decorators are applied as plain function calls here
15
+ // rather than `@decorator` syntax, and why order matters.
16
+
17
+ function schemaFor(target: Function): JsonSchema {
18
+ return getReadModelMetadata(target)!.schema;
19
+ }
20
+
21
+ describe('JsonSchemaGenerator - security metadata', () => {
22
+ describe('when a class is marked @encrypted() at the class level', () => {
23
+ class PartnerCredentials {
24
+ apiKey = '';
25
+ webhookSecret = '';
26
+ }
27
+ encrypted()(PartnerCredentials);
28
+
29
+ readModel()(PartnerCredentials);
30
+ const schema = schemaFor(PartnerCredentials);
31
+
32
+ it('should mark every one of its own properties as EncryptedSubject', () => {
33
+ expect(schema.properties!.apiKey.security).toEqual([{ metadataType: 'EncryptedSubject', details: '' }]);
34
+ expect(schema.properties!.webhookSecret.security).toEqual([{ metadataType: 'EncryptedSubject', details: '' }]);
35
+ });
36
+
37
+ it('should not write anything to the compliance key', () => {
38
+ expect(schema.properties!.apiKey.compliance).toBeUndefined();
39
+ expect(schema.properties!.webhookSecret.compliance).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe('when only a single property is marked @encrypted()', () => {
44
+ class Integration {
45
+ apiKey = '';
46
+ name = '';
47
+ }
48
+ encrypted(EncryptionScope.Subject, 'Partner API key')(Integration.prototype, 'apiKey');
49
+ readModel()(Integration);
50
+ const schema = schemaFor(Integration);
51
+
52
+ it('should mark the decorated property as EncryptedSubject', () => {
53
+ expect(schema.properties!.apiKey.security).toEqual([{ metadataType: 'EncryptedSubject', details: 'Partner API key' }]);
54
+ });
55
+
56
+ it('should leave the other property without security metadata', () => {
57
+ expect(schema.properties!.name.security).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe.each([
62
+ [EncryptionScope.Subject, 'EncryptedSubject'],
63
+ [EncryptionScope.Namespace, 'EncryptedNamespace'],
64
+ [EncryptionScope.Global, 'EncryptedGlobal']
65
+ ])('when a property is marked @encrypted() with scope %s', (scope, expectedMetadataType) => {
66
+ class Secret {
67
+ value = '';
68
+ }
69
+ encrypted(scope)(Secret.prototype, 'value');
70
+ readModel()(Secret);
71
+ const schema = schemaFor(Secret);
72
+
73
+ it(`should resolve to metadataType ${expectedMetadataType}`, () => {
74
+ expect(schema.properties!.value.security).toEqual([{ metadataType: expectedMetadataType, details: '' }]);
75
+ });
76
+ });
77
+
78
+ describe('when a property is typed as a ConceptAs<T> marked @encrypted()', () => {
79
+ class ApiKey extends ConceptAs<string> {
80
+ constructor(value: string) {
81
+ super(value);
82
+ }
83
+ }
84
+ encrypted(EncryptionScope.Namespace, 'Shared namespace key')(ApiKey);
85
+
86
+ class Configuration {
87
+ key: ApiKey = new ApiKey('');
88
+ }
89
+ readModel()(Configuration);
90
+ const schema = schemaFor(Configuration);
91
+
92
+ it('should mark the concept-typed property as EncryptedNamespace', () => {
93
+ expect(schema.properties!.key.security).toEqual([{ metadataType: 'EncryptedNamespace', details: 'Shared namespace key' }]);
94
+ });
95
+ });
96
+
97
+ describe('when a property typed as a nested composite value object is marked @encrypted()', () => {
98
+ class ContactDetails {
99
+ phone = '';
100
+ fax = '';
101
+ }
102
+ class Vendor {
103
+ contact: ContactDetails = new ContactDetails();
104
+ }
105
+ encrypted(EncryptionScope.Subject, 'Vendor contact information')(Vendor.prototype, 'contact');
106
+ readModel()(Vendor);
107
+ const schema = schemaFor(Vendor);
108
+
109
+ it('should push the security metadata down onto every leaf property', () => {
110
+ expect(schema.properties!.contact.properties!.phone.security).toEqual([{ metadataType: 'EncryptedSubject', details: 'Vendor contact information' }]);
111
+ expect(schema.properties!.contact.properties!.fax.security).toEqual([{ metadataType: 'EncryptedSubject', details: 'Vendor contact information' }]);
112
+ });
113
+
114
+ it('should not leave security metadata on the container node itself', () => {
115
+ expect(schema.properties!.contact.security).toBeUndefined();
116
+ });
117
+ });
118
+
119
+ describe('when an array element is a ConceptAs<T> marked @encrypted()', () => {
120
+ class Token extends ConceptAs<string> {
121
+ constructor(value: string) {
122
+ super(value);
123
+ }
124
+ }
125
+ encrypted(EncryptionScope.Subject, 'Rotating token')(Token);
126
+
127
+ class Session {
128
+ tokens: Token[] = [];
129
+ }
130
+ field(Array, { enumerable: true, genericArguments: [Token] })(Session.prototype, 'tokens');
131
+ readModel()(Session);
132
+ const schema = schemaFor(Session);
133
+
134
+ it('should carry the element concept security metadata onto the item schema', () => {
135
+ expect(schema.properties!.tokens.items!.security).toEqual([{ metadataType: 'EncryptedSubject', details: 'Rotating token' }]);
136
+ });
137
+
138
+ it('should not leave coarse security metadata on the array container itself', () => {
139
+ expect(schema.properties!.tokens.security).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ describe('when a property is marked both @pii() and @encrypted()', () => {
144
+ class ConflictedValue {
145
+ value = '';
146
+ }
147
+ pii()(ConflictedValue.prototype, 'value');
148
+ encrypted()(ConflictedValue.prototype, 'value');
149
+
150
+ it('should throw PIIAndEncryptedCombinedNotSupported when generating the schema', () => {
151
+ expect(() => { readModel()(ConflictedValue); }).toThrow(PIIAndEncryptedCombinedNotSupported);
152
+ });
153
+ });
154
+
155
+ describe('when a class-level @pii() combines with a property-level @encrypted() on the same property', () => {
156
+ class MixedClass {
157
+ value = '';
158
+ }
159
+ pii()(MixedClass);
160
+ encrypted()(MixedClass.prototype, 'value');
161
+
162
+ it('should throw PIIAndEncryptedCombinedNotSupported when generating the schema', () => {
163
+ expect(() => { readModel()(MixedClass); }).toThrow(PIIAndEncryptedCombinedNotSupported);
164
+ });
165
+ });
166
+
167
+ describe('when a class carries only @pii() properties alongside a class that carries only @encrypted() properties', () => {
168
+ class OnlyPii {
169
+ ssn = '';
170
+ }
171
+ pii()(OnlyPii.prototype, 'ssn');
172
+ readModel()(OnlyPii);
173
+
174
+ class OnlyEncrypted {
175
+ apiKey = '';
176
+ }
177
+ encrypted()(OnlyEncrypted.prototype, 'apiKey');
178
+ readModel()(OnlyEncrypted);
179
+
180
+ it('should not throw for either type', () => {
181
+ expect(() => schemaFor(OnlyPii)).not.toThrow();
182
+ expect(() => schemaFor(OnlyEncrypted)).not.toThrow();
183
+ });
184
+
185
+ it('should keep compliance and security metadata on separate schema keys', () => {
186
+ const piiSchema = schemaFor(OnlyPii);
187
+ const encryptedSchema = schemaFor(OnlyEncrypted);
188
+ expect(piiSchema.properties!.ssn.compliance).toEqual([{ metadataType: 'PII', details: '' }]);
189
+ expect(piiSchema.properties!.ssn.security).toBeUndefined();
190
+ expect(encryptedSchema.properties!.apiKey.security).toEqual([{ metadataType: 'EncryptedSubject', details: '' }]);
191
+ expect(encryptedSchema.properties!.apiKey.compliance).toBeUndefined();
192
+ });
193
+ });
194
+ });
@@ -3,10 +3,13 @@
3
3
 
4
4
  import 'reflect-metadata';
5
5
  import { ConceptAs, Constructor, Fields, Guid } from '@cratis/fundamentals';
6
- import { ComplianceSchemaMetadata, JsonSchema } from './JsonSchema';
6
+ import { ComplianceSchemaMetadata, JsonSchema, SecuritySchemaMetadata } from './JsonSchema';
7
7
  import { TypeIntrospector } from '../types';
8
8
  import { ComplianceMetadata } from '../compliance/ComplianceMetadata';
9
9
  import { ComplianceMetadataResolver } from '../compliance/ComplianceMetadataResolver';
10
+ import { SecurityMetadata } from '../confidentiality/SecurityMetadata';
11
+ import { SecurityMetadataResolver } from '../confidentiality/SecurityMetadataResolver';
12
+ import { PIIAndEncryptedCombinedNotSupported } from '../confidentiality/PIIAndEncryptedCombinedNotSupported';
10
13
 
11
14
  /**
12
15
  * Generates JSON schemas for class constructors using reflection metadata.
@@ -54,11 +57,15 @@ export class JsonSchemaGenerator {
54
57
  // the runtime type was unavailable (e.g. esbuild/tsx omits design:paramtypes).
55
58
  if (Object.keys(propertySchema).length > 0) {
56
59
  // An array of concept elements resolves and applies its own item-level compliance
57
- // inside mapRuntimeTypeToSchema - the general property/type/declaring-class walk
58
- // below is skipped for it, mirroring the C# generator's enumerable-of-concept branch.
60
+ // and security metadata inside mapRuntimeTypeToSchema - the general
61
+ // property/type/declaring-class walk below is skipped for it, mirroring the C#
62
+ // generator's enumerable-of-concept branch.
59
63
  if (!this.isConceptArrayMember(target, memberName, memberType)) {
60
- const metadata = this.collectComplianceMetadata(prototype, memberName, memberType);
61
- this.addComplianceMetadataToSchema(propertySchema, metadata);
64
+ const complianceMetadata = this.collectComplianceMetadata(prototype, memberName, memberType);
65
+ const securityMetadata = this.collectSecurityMetadata(prototype, memberName, memberType);
66
+ this.throwIfBothCompliantAndSecure(memberName, complianceMetadata, securityMetadata);
67
+ this.addComplianceMetadataToSchema(propertySchema, complianceMetadata);
68
+ this.addSecurityMetadataToSchema(propertySchema, securityMetadata);
62
69
  }
63
70
  schemaProperties[memberName] = propertySchema;
64
71
  }
@@ -132,13 +139,16 @@ export class JsonSchemaGenerator {
132
139
  const elementType = this.getArrayElementType(declaringType, propertyName);
133
140
 
134
141
  // An array whose element is a ConceptAs<T> loses its classification the moment it is put in
135
- // a list unless the element concept's own compliance metadata is carried onto the item schema -
136
- // a value that would be encrypted as a scalar would otherwise be persisted in the clear as a
137
- // list element. Mirrors the C# generator's explicit enumerable-of-concept branch.
142
+ // a list unless the element concept's own compliance/security metadata is carried onto the
143
+ // item schema - a value that would be encrypted as a scalar would otherwise be persisted in
144
+ // the clear as a list element. Mirrors the C# generator's explicit enumerable-of-concept branch.
138
145
  if (elementType && this.isConceptAs(elementType)) {
139
146
  const itemSchema = this.mapRuntimeTypeToSchema(elementType);
140
- const metadata = ComplianceMetadataResolver.getMetadataForType(elementType);
141
- this.addComplianceMetadataToSchema(itemSchema, metadata);
147
+ const complianceMetadata = ComplianceMetadataResolver.getMetadataForType(elementType);
148
+ const securityMetadata = SecurityMetadataResolver.getMetadataForType(elementType);
149
+ this.throwIfBothCompliantAndSecure(elementType.name, complianceMetadata, securityMetadata);
150
+ this.addComplianceMetadataToSchema(itemSchema, complianceMetadata);
151
+ this.addSecurityMetadataToSchema(itemSchema, securityMetadata);
142
152
  return { type: 'array', items: itemSchema };
143
153
  }
144
154
 
@@ -299,4 +309,99 @@ export class JsonSchemaGenerator {
299
309
  private static hasComplianceMetadataOfType(compliance: ComplianceSchemaMetadata[], metadataType: string): boolean {
300
310
  return compliance.some(item => item.metadataType === metadataType);
301
311
  }
312
+
313
+ /**
314
+ * Collects security metadata for a property from every source compliance metadata is resolved
315
+ * from: the property itself, its declaring class, and its own type (the concept case). This is
316
+ * the security counterpart to {@link collectComplianceMetadata} - a deliberately separate walk
317
+ * rather than a shared one, over a completely separate `@encrypted()` decorator vocabulary.
318
+ * @param target - The class prototype the property is declared on.
319
+ * @param propertyKey - The property name.
320
+ * @param propertyType - The property's runtime type, when resolved.
321
+ * @returns The collected security metadata, in property → declaring-class → type order.
322
+ */
323
+ private static collectSecurityMetadata(target: object, propertyKey: string, propertyType?: Function): SecurityMetadata[] {
324
+ const metadata: SecurityMetadata[] = [];
325
+
326
+ // Property-level security decorator.
327
+ if (SecurityMetadataResolver.hasMetadataFor(target, propertyKey)) {
328
+ metadata.push(...SecurityMetadataResolver.getMetadataFor(target, propertyKey));
329
+ }
330
+
331
+ // Declaring class-level security decorator - a class-level @encrypted() marks every one of
332
+ // its own properties, the same way a class-level @pii() does for compliance.
333
+ const declaringClass = (target as { constructor?: Function }).constructor;
334
+ if (declaringClass) {
335
+ metadata.push(...SecurityMetadataResolver.getMetadataForType(declaringClass));
336
+ }
337
+
338
+ // Type-level security decorator on the property's own type (e.g., @encrypted on a ConceptAs).
339
+ if (propertyType) {
340
+ metadata.push(...SecurityMetadataResolver.getMetadataForType(propertyType));
341
+ }
342
+
343
+ return metadata;
344
+ }
345
+
346
+ /**
347
+ * Adds security metadata to a schema node, descending into an object's properties so that the
348
+ * metadata always lands on the leaves that actually hold a value. This is the security
349
+ * counterpart to {@link addComplianceMetadataToSchema} - see its remarks for why metadata is
350
+ * pushed to leaves and why an array is left as a container.
351
+ * @param schema - The schema node to add to.
352
+ * @param metadata - The security metadata to add.
353
+ */
354
+ private static addSecurityMetadataToSchema(schema: JsonSchema, metadata: SecurityMetadata[]): void {
355
+ if (metadata.length === 0) {
356
+ return;
357
+ }
358
+
359
+ if (schema.properties && Object.keys(schema.properties).length > 0) {
360
+ for (const propertySchema of Object.values(schema.properties)) {
361
+ this.addSecurityMetadataToSchema(propertySchema, metadata);
362
+ }
363
+ return;
364
+ }
365
+
366
+ const security = schema.security ?? [];
367
+ for (const item of metadata) {
368
+ const metadataType = item.metadataType.value.toString();
369
+ if (!this.hasSecurityMetadataOfType(security, metadataType)) {
370
+ security.push({ metadataType, details: item.details });
371
+ }
372
+ }
373
+
374
+ if (security.length > 0) {
375
+ schema.security = security;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Checks whether a security array already carries metadata of a given type.
381
+ * @param security - The security array to check.
382
+ * @param metadataType - The metadata type to look for.
383
+ * @returns True when the metadata type is already present, false if not.
384
+ */
385
+ private static hasSecurityMetadataOfType(security: SecuritySchemaMetadata[], metadataType: string): boolean {
386
+ return security.some(item => item.metadataType === metadataType);
387
+ }
388
+
389
+ /**
390
+ * Rejects a property or type that resolved both compliance and security metadata.
391
+ * @param name - The property or type name, used in the thrown error.
392
+ * @param complianceMetadata - The compliance metadata collected for the property/type.
393
+ * @param securityMetadata - The security metadata collected for the property/type.
394
+ * @remarks
395
+ * This is not merely redundant - it corrupts the value. The kernel applies every matching
396
+ * handler for a property in sequence, so a value marked both ways is encrypted first under the
397
+ * PII key and then again under the Encrypted key; releasing it decrypts with the wrong key
398
+ * against ciphertext, which fails loudly rather than returning a wrong value. Checked here, at
399
+ * schema-generation time, the same point C#'s `EncryptedMetadataProvider.ThrowIfAlsoPII` checks
400
+ * it - this client has no compile-time analyzer, so this runtime check is the only backstop.
401
+ */
402
+ private static throwIfBothCompliantAndSecure(name: string, complianceMetadata: ComplianceMetadata[], securityMetadata: SecurityMetadata[]): void {
403
+ if (complianceMetadata.length > 0 && securityMetadata.length > 0) {
404
+ throw new PIIAndEncryptedCombinedNotSupported(name);
405
+ }
406
+ }
302
407
  }
@@ -30,5 +30,8 @@ export enum DecoratorType {
30
30
  Webhook = 'webhook',
31
31
 
32
32
  /** Event type migration artifacts discovered through the eventTypeMigration decorator. */
33
- EventTypeMigration = 'eventTypeMigration'
33
+ EventTypeMigration = 'eventTypeMigration',
34
+
35
+ /** Global read model variant handler artifacts discovered through the globalFor decorator. */
36
+ GlobalForHandler = 'globalForHandler'
34
37
  }