@tomgiee/tsdp 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +0 -39
  2. package/dist/src/builder/media-builder.d.ts.map +1 -1
  3. package/dist/src/builder/media-builder.js +5 -12
  4. package/dist/src/builder/session-builder.d.ts.map +1 -1
  5. package/dist/src/builder/session-builder.js +4 -14
  6. package/dist/src/index.d.ts +6 -10
  7. package/dist/src/index.d.ts.map +1 -1
  8. package/dist/src/index.js +37 -49
  9. package/dist/src/parser/core.d.ts +366 -0
  10. package/dist/src/parser/core.d.ts.map +1 -0
  11. package/dist/src/parser/core.js +802 -0
  12. package/dist/src/parser/field-parser.d.ts +51 -8
  13. package/dist/src/parser/field-parser.d.ts.map +1 -1
  14. package/dist/src/parser/field-parser.js +91 -23
  15. package/dist/src/parser/media-parser.d.ts +1 -1
  16. package/dist/src/parser/media-parser.d.ts.map +1 -1
  17. package/dist/src/parser/media-parser.js +9 -15
  18. package/dist/src/parser/session-parser.d.ts.map +1 -1
  19. package/dist/src/parser/session-parser.js +16 -17
  20. package/dist/src/parser/time-parser.d.ts +1 -1
  21. package/dist/src/parser/time-parser.d.ts.map +1 -1
  22. package/dist/src/parser/time-parser.js +8 -8
  23. package/dist/src/serializer/fields.d.ts +167 -0
  24. package/dist/src/serializer/fields.d.ts.map +1 -0
  25. package/dist/src/serializer/fields.js +320 -0
  26. package/dist/src/serializer/media-serializer.js +6 -7
  27. package/dist/src/serializer/session-serializer.js +13 -15
  28. package/dist/src/types/attributes.d.ts.map +1 -1
  29. package/dist/src/types/fields.d.ts +5 -5
  30. package/dist/src/types/fields.d.ts.map +1 -1
  31. package/dist/src/types/fields.js +4 -4
  32. package/dist/src/types/media.d.ts +9 -10
  33. package/dist/src/types/media.d.ts.map +1 -1
  34. package/dist/src/types/media.js +2 -4
  35. package/dist/src/types/network.d.ts +15 -56
  36. package/dist/src/types/network.d.ts.map +1 -1
  37. package/dist/src/types/network.js +3 -34
  38. package/dist/src/types/primitives.d.ts +3 -147
  39. package/dist/src/types/primitives.d.ts.map +1 -1
  40. package/dist/src/types/primitives.js +2 -171
  41. package/dist/src/types/session.d.ts +14 -14
  42. package/dist/src/types/session.d.ts.map +1 -1
  43. package/dist/src/types/time.d.ts +4 -4
  44. package/dist/src/types/time.d.ts.map +1 -1
  45. package/dist/src/types/time.js +8 -6
  46. package/dist/src/utils/address-parser.d.ts +4 -4
  47. package/dist/src/utils/address-parser.d.ts.map +1 -1
  48. package/dist/src/utils/address-parser.js +9 -16
  49. package/dist/src/validator/validator.d.ts +94 -0
  50. package/dist/src/validator/validator.d.ts.map +1 -0
  51. package/dist/src/validator/validator.js +573 -0
  52. package/dist/tests/unit/parser/attribute-parser.test.js +106 -107
  53. package/dist/tests/unit/parser/field-parser.test.js +66 -66
  54. package/dist/tests/unit/parser/media-parser.test.js +38 -38
  55. package/dist/tests/unit/parser/primitive-parser.test.js +89 -90
  56. package/dist/tests/unit/parser/scanner.test.js +32 -32
  57. package/dist/tests/unit/parser/time-parser.test.js +22 -22
  58. package/dist/tests/unit/serializer/attribute-serializer.test.js +22 -22
  59. package/dist/tests/unit/serializer/field-serializer.test.js +57 -57
  60. package/dist/tests/unit/serializer/media-serializer.test.js +5 -6
  61. package/dist/tests/unit/serializer/session-serializer.test.js +24 -24
  62. package/dist/tests/unit/serializer/time-serializer.test.js +16 -16
  63. package/dist/tests/unit/types/network.test.js +21 -56
  64. package/dist/tests/unit/types/primitives.test.js +0 -39
  65. package/dist/tests/unit/validator/media-validator.test.js +34 -35
  66. package/dist/tests/unit/validator/semantic-validator.test.js +36 -37
  67. package/dist/tests/unit/validator/session-validator.test.js +54 -54
  68. package/package.json +1 -1
@@ -0,0 +1,573 @@
1
+ "use strict";
2
+ /**
3
+ * Validators for SDP (RFC 8866)
4
+ *
5
+ * This module consolidates:
6
+ * - Session-level validation
7
+ * - Media description validation
8
+ * - Cross-field semantic validation
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.validResult = validResult;
12
+ exports.invalidResult = invalidResult;
13
+ exports.validateSdp = validateSdp;
14
+ exports.validateSessionDescription = validateSessionDescription;
15
+ exports.validateMediaDescription = validateMediaDescription;
16
+ exports.validateAllMediaDescriptions = validateAllMediaDescriptions;
17
+ exports.validateSemanticConstraints = validateSemanticConstraints;
18
+ const errors_1 = require("../types/errors");
19
+ const primitives_1 = require("../types/primitives");
20
+ const attributes_1 = require("../types/attributes");
21
+ const format_validators_1 = require("../utils/format-validators");
22
+ /**
23
+ * Create a valid result
24
+ */
25
+ function validResult() {
26
+ return { valid: true, errors: [] };
27
+ }
28
+ /**
29
+ * Create an invalid result with errors
30
+ */
31
+ function invalidResult(errors) {
32
+ return { valid: false, errors };
33
+ }
34
+ // ============================================================================
35
+ // Main Validation Entry Point
36
+ // ============================================================================
37
+ /**
38
+ * Perform complete semantic validation of a session description
39
+ *
40
+ * This is the main validation entry point that checks:
41
+ * 1. Session-level validation
42
+ * 2. Media-level validation
43
+ * 3. Cross-field semantic constraints
44
+ *
45
+ * @param session - Session description to validate
46
+ * @param options - Optional validation options
47
+ * @returns ValidationResult with all errors found
48
+ */
49
+ function validateSdp(session, options = {}) {
50
+ const allErrors = [];
51
+ // 1. Session-level validation
52
+ const sessionResult = validateSessionDescription(session);
53
+ allErrors.push(...sessionResult.errors);
54
+ // 2. Check for deprecated key field
55
+ if (!options.allowDeprecatedKeyField && session.key !== undefined) {
56
+ allErrors.push(errors_1.ValidationError.constraintViolation('The k= field is deprecated per RFC 8866', 'RFC8866:5.12'));
57
+ }
58
+ // 3. Require session-level connection if specified
59
+ if (options.requireSessionConnection && session.connection === undefined) {
60
+ allErrors.push(errors_1.ValidationError.constraintViolation('Session-level connection information is required', 'RFC8866:5.7'));
61
+ }
62
+ // 4. Media-level validation
63
+ const hasSessionConnection = session.connection !== undefined;
64
+ const mediaResult = validateAllMediaDescriptions(session.mediaDescriptions, hasSessionConnection);
65
+ allErrors.push(...mediaResult.errors);
66
+ // 5. Cross-field semantic validation (if not skipped)
67
+ if (!options.skipSemanticValidation) {
68
+ const semanticResult = validateSemanticConstraints(session);
69
+ allErrors.push(...semanticResult.errors);
70
+ }
71
+ return allErrors.length === 0 ? validResult() : invalidResult(allErrors);
72
+ }
73
+ // ============================================================================
74
+ // Session Validator
75
+ // ============================================================================
76
+ /**
77
+ * Validate a complete session description
78
+ *
79
+ * Checks RFC 8866 Section 5 requirements:
80
+ * - Required fields are present (v, o, s, t)
81
+ * - Version is 0
82
+ * - At least one time description
83
+ * - Connection coverage (session or all media)
84
+ *
85
+ * @param session - Session description to validate
86
+ * @returns ValidationResult with any errors found
87
+ */
88
+ function validateSessionDescription(session) {
89
+ const errors = [];
90
+ // 1. Validate version (RFC 8866 Section 5.1)
91
+ validateVersion(session.version, errors);
92
+ // 2. Validate origin (RFC 8866 Section 5.2)
93
+ validateOrigin(session, errors);
94
+ // 3. Validate session name (RFC 8866 Section 5.3)
95
+ validateSessionName(session, errors);
96
+ // 4. Validate time descriptions (RFC 8866 Section 5.9)
97
+ validateTimeDescriptions(session, errors);
98
+ // 5. Validate connection coverage (RFC 8866 Section 5.7)
99
+ validateConnectionCoverage(session, errors);
100
+ // 6. Validate bandwidths (RFC 8866 Section 5.8)
101
+ validateBandwidths(session, errors);
102
+ // 7. Validate attributes (RFC 8866 Section 6)
103
+ validateSessionAttributes(session, errors);
104
+ // 8. Validate email format (RFC 8866 Section 5.6)
105
+ validateEmails(session, errors);
106
+ // 9. Validate phone format (RFC 8866 Section 5.6)
107
+ validatePhones(session, errors);
108
+ // 10. Validate URI format (RFC 8866 Section 5.5)
109
+ validateUri(session, errors);
110
+ return errors.length === 0 ? validResult() : invalidResult(errors);
111
+ }
112
+ /**
113
+ * Validate version field
114
+ */
115
+ function validateVersion(version, errors) {
116
+ if (version !== 0) {
117
+ errors.push(errors_1.ValidationError.invalidValue('version', version, 'SDP version must be 0', 'RFC8866:5.1'));
118
+ }
119
+ }
120
+ /**
121
+ * Validate origin field
122
+ */
123
+ function validateOrigin(session, errors) {
124
+ if (!session.origin) {
125
+ errors.push(errors_1.ValidationError.missingField('origin', 'RFC8866:5.2'));
126
+ return;
127
+ }
128
+ if (!session.origin.username || session.origin.username.length === 0) {
129
+ errors.push(errors_1.ValidationError.invalidValue('origin.username', session.origin.username, 'Username cannot be empty', 'RFC8866:5.2'));
130
+ }
131
+ if (!session.origin.sessId || session.origin.sessId.length === 0) {
132
+ errors.push(errors_1.ValidationError.invalidValue('origin.sessId', session.origin.sessId, 'Session ID cannot be empty', 'RFC8866:5.2'));
133
+ }
134
+ else if (!(0, primitives_1.isNumericString)(session.origin.sessId)) {
135
+ errors.push(errors_1.ValidationError.invalidValue('origin.sessId', session.origin.sessId, 'Session ID must contain only digits (0-9)', 'RFC8866:5.2'));
136
+ }
137
+ if (!session.origin.sessVersion || session.origin.sessVersion.length === 0) {
138
+ errors.push(errors_1.ValidationError.invalidValue('origin.sessVersion', session.origin.sessVersion, 'Session version cannot be empty', 'RFC8866:5.2'));
139
+ }
140
+ else if (!(0, primitives_1.isNumericString)(session.origin.sessVersion)) {
141
+ errors.push(errors_1.ValidationError.invalidValue('origin.sessVersion', session.origin.sessVersion, 'Session version must contain only digits (0-9)', 'RFC8866:5.2'));
142
+ }
143
+ if (!session.origin.netType || session.origin.netType.length === 0) {
144
+ errors.push(errors_1.ValidationError.invalidValue('origin.netType', session.origin.netType, 'Network type cannot be empty', 'RFC8866:5.2'));
145
+ }
146
+ if (!session.origin.addrType || session.origin.addrType.length === 0) {
147
+ errors.push(errors_1.ValidationError.invalidValue('origin.addrType', session.origin.addrType, 'Address type cannot be empty', 'RFC8866:5.2'));
148
+ }
149
+ if (!session.origin.unicastAddress || session.origin.unicastAddress.length === 0) {
150
+ errors.push(errors_1.ValidationError.invalidValue('origin.unicastAddress', session.origin.unicastAddress, 'Unicast address cannot be empty', 'RFC8866:5.2'));
151
+ }
152
+ }
153
+ /**
154
+ * Validate session name field
155
+ */
156
+ function validateSessionName(session, errors) {
157
+ if (session.sessionName === undefined || session.sessionName === null) {
158
+ errors.push(errors_1.ValidationError.missingField('sessionName', 'RFC8866:5.3'));
159
+ }
160
+ else if (session.sessionName.length === 0) {
161
+ errors.push(errors_1.ValidationError.invalidValue('sessionName', session.sessionName, 'Session name cannot be empty (use " " if no meaningful name)', 'RFC8866:5.3'));
162
+ }
163
+ }
164
+ /**
165
+ * Validate time descriptions
166
+ */
167
+ function validateTimeDescriptions(session, errors) {
168
+ if (!session.timeDescriptions || session.timeDescriptions.length === 0) {
169
+ errors.push(errors_1.ValidationError.constraintViolation('At least one time description (t=) is required', 'RFC8866:5.9'));
170
+ return;
171
+ }
172
+ for (let i = 0; i < session.timeDescriptions.length; i++) {
173
+ const td = session.timeDescriptions[i];
174
+ if (!td.timing) {
175
+ errors.push(errors_1.ValidationError.missingField(`timeDescriptions[${i}].timing`, 'RFC8866:5.9'));
176
+ continue;
177
+ }
178
+ const { startTime, stopTime } = td.timing;
179
+ if (startTime !== 0 && stopTime !== 0 && startTime > stopTime) {
180
+ errors.push(errors_1.ValidationError.constraintViolation(`Time description ${i}: start time (${startTime}) must be before or equal to stop time (${stopTime})`, 'RFC8866:5.9'));
181
+ }
182
+ for (let j = 0; j < td.repeats.length; j++) {
183
+ const repeat = td.repeats[j];
184
+ if (!repeat.offsets || repeat.offsets.length === 0) {
185
+ errors.push(errors_1.ValidationError.constraintViolation(`Time description ${i}, repeat ${j}: at least one offset is required`, 'RFC8866:5.10'));
186
+ }
187
+ }
188
+ if (td.timezone) {
189
+ if (!td.timezone.adjustments || td.timezone.adjustments.length === 0) {
190
+ errors.push(errors_1.ValidationError.constraintViolation(`Time description ${i}: timezone field requires at least one adjustment`, 'RFC8866:5.11'));
191
+ }
192
+ }
193
+ }
194
+ }
195
+ /**
196
+ * Validate connection coverage
197
+ */
198
+ function validateConnectionCoverage(session, errors) {
199
+ var _a;
200
+ const hasSessionConnection = session.connection !== undefined;
201
+ const mediaDescriptions = (_a = session.mediaDescriptions) !== null && _a !== void 0 ? _a : [];
202
+ if (mediaDescriptions.length === 0) {
203
+ return;
204
+ }
205
+ if (hasSessionConnection) {
206
+ return;
207
+ }
208
+ for (let i = 0; i < mediaDescriptions.length; i++) {
209
+ const md = mediaDescriptions[i];
210
+ if (!md.connections || md.connections.length === 0) {
211
+ errors.push(errors_1.ValidationError.constraintViolation(`Media description ${i} (${md.media.type}): connection information required when no session-level connection is present`, 'RFC8866:5.7'));
212
+ }
213
+ }
214
+ }
215
+ /**
216
+ * Validate bandwidth fields
217
+ */
218
+ function validateBandwidths(session, errors) {
219
+ for (let i = 0; i < session.bandwidths.length; i++) {
220
+ const bw = session.bandwidths[i];
221
+ if (bw.value < 0) {
222
+ errors.push(errors_1.ValidationError.invalidValue(`bandwidths[${i}].value`, bw.value, 'Bandwidth value must be non-negative', 'RFC8866:5.8'));
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Validate session-level attributes
228
+ */
229
+ function validateSessionAttributes(session, errors) {
230
+ const directionAttrs = ['sendrecv', 'sendonly', 'recvonly', 'inactive'];
231
+ let directionCount = 0;
232
+ for (const attr of session.attributes) {
233
+ if (attr.kind === 'property' && directionAttrs.includes(attr.name)) {
234
+ directionCount++;
235
+ }
236
+ if (attributes_1.MEDIA_ONLY_ATTRIBUTES.includes(attr.name)) {
237
+ errors.push(errors_1.ValidationError.constraintViolation(`Attribute '${attr.name}' is only valid at media level, not session level`, 'RFC8866:6'));
238
+ }
239
+ }
240
+ if (directionCount > 1) {
241
+ errors.push(errors_1.ValidationError.constraintViolation('Only one direction attribute (sendrecv/sendonly/recvonly/inactive) allowed at session level', 'RFC8866:6.7'));
242
+ }
243
+ }
244
+ /**
245
+ * Validate email addresses
246
+ */
247
+ function validateEmails(session, errors) {
248
+ if (!session.emails || session.emails.length === 0) {
249
+ return;
250
+ }
251
+ for (let i = 0; i < session.emails.length; i++) {
252
+ const email = session.emails[i];
253
+ if (!(0, format_validators_1.isValidEmailFormat)(email)) {
254
+ errors.push(errors_1.ValidationError.invalidValue(`emails[${i}]`, email, 'Invalid email format. Expected: addr-spec, addr-spec (comment), or Display Name <addr-spec>', 'RFC8866:5.6'));
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * Validate phone numbers
260
+ */
261
+ function validatePhones(session, errors) {
262
+ if (!session.phones || session.phones.length === 0) {
263
+ return;
264
+ }
265
+ for (let i = 0; i < session.phones.length; i++) {
266
+ const phone = session.phones[i];
267
+ if (!(0, format_validators_1.isValidPhoneFormat)(phone)) {
268
+ errors.push(errors_1.ValidationError.invalidValue(`phones[${i}]`, phone, 'Invalid phone format. Expected: phone, phone (comment), or Display Name <phone>', 'RFC8866:5.6'));
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Validate URI
274
+ */
275
+ function validateUri(session, errors) {
276
+ if (!session.uri) {
277
+ return;
278
+ }
279
+ if (!(0, format_validators_1.isValidURIReference)(session.uri)) {
280
+ errors.push(errors_1.ValidationError.invalidValue('uri', session.uri, 'Invalid URI format per RFC 3986', 'RFC8866:5.5'));
281
+ }
282
+ }
283
+ // ============================================================================
284
+ // Media Description Validator
285
+ // ============================================================================
286
+ /**
287
+ * Validate a media description
288
+ *
289
+ * @param media - Media description to validate
290
+ * @param index - Index of media description (for error messages)
291
+ * @param hasSessionConnection - Whether session-level connection exists
292
+ * @returns ValidationResult with any errors found
293
+ */
294
+ function validateMediaDescription(media, index, hasSessionConnection) {
295
+ const errors = [];
296
+ const prefix = `mediaDescriptions[${index}]`;
297
+ validateMediaField(media, prefix, errors);
298
+ validatePort(media, prefix, errors);
299
+ validateFormats(media, prefix, errors);
300
+ if (!hasSessionConnection) {
301
+ validateMediaConnection(media, prefix, errors);
302
+ }
303
+ validateMediaBandwidths(media, prefix, errors);
304
+ validateMediaAttributes(media, prefix, errors);
305
+ return errors.length === 0 ? validResult() : invalidResult(errors);
306
+ }
307
+ /**
308
+ * Validate media field
309
+ */
310
+ function validateMediaField(media, prefix, errors) {
311
+ if (!media.media.type || media.media.type.length === 0) {
312
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.media.type`, media.media.type, 'Media type cannot be empty', 'RFC8866:5.14'));
313
+ }
314
+ if (!media.media.proto || media.media.proto.length === 0) {
315
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.media.proto`, media.media.proto, 'Protocol cannot be empty', 'RFC8866:5.14'));
316
+ }
317
+ }
318
+ /**
319
+ * Validate port
320
+ */
321
+ function validatePort(media, prefix, errors) {
322
+ const port = media.media.port;
323
+ if (port.kind === 'simple') {
324
+ if (port.value < 0 || port.value > 65535) {
325
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.media.port`, port.value, 'Port must be 0-65535', 'RFC8866:5.14'));
326
+ }
327
+ }
328
+ else if (port.kind === 'range') {
329
+ if (port.base < 0 || port.base > 65535) {
330
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.media.port.base`, port.base, 'Port must be 0-65535', 'RFC8866:5.14'));
331
+ }
332
+ if (port.count < 1) {
333
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.media.port.count`, port.count, 'Port count must be at least 1', 'RFC8866:5.14'));
334
+ }
335
+ if (port.base + port.count - 1 > 65535) {
336
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: port range ${port.base}/${port.count} exceeds 65535`, 'RFC8866:5.14'));
337
+ }
338
+ }
339
+ }
340
+ /**
341
+ * Validate formats
342
+ */
343
+ function validateFormats(media, prefix, errors) {
344
+ if (!media.media.formats || media.media.formats.length === 0) {
345
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: at least one media format is required`, 'RFC8866:5.14'));
346
+ }
347
+ }
348
+ /**
349
+ * Validate media-level connection
350
+ */
351
+ function validateMediaConnection(media, prefix, errors) {
352
+ if (!media.connections || media.connections.length === 0) {
353
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: connection information required when no session-level connection`, 'RFC8866:5.7'));
354
+ }
355
+ }
356
+ /**
357
+ * Validate media-level bandwidths
358
+ */
359
+ function validateMediaBandwidths(media, prefix, errors) {
360
+ for (let i = 0; i < media.bandwidths.length; i++) {
361
+ const bw = media.bandwidths[i];
362
+ if (bw.value < 0) {
363
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.bandwidths[${i}].value`, bw.value, 'Bandwidth value must be non-negative', 'RFC8866:5.8'));
364
+ }
365
+ }
366
+ }
367
+ /**
368
+ * Validate media-level attributes
369
+ */
370
+ function validateMediaAttributes(media, prefix, errors) {
371
+ const directionAttrs = ['sendrecv', 'sendonly', 'recvonly', 'inactive'];
372
+ let directionCount = 0;
373
+ const rtpmapPayloadTypes = new Set();
374
+ const rtpmapPayloadTypeDuplicates = new Set();
375
+ const fmtpFormats = new Set();
376
+ const fmtpFormatDuplicates = new Set();
377
+ for (const attr of media.attributes) {
378
+ if (attr.kind === 'property' && directionAttrs.includes(attr.name)) {
379
+ directionCount++;
380
+ }
381
+ if (attributes_1.SESSION_ONLY_ATTRIBUTES.includes(attr.name)) {
382
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: attribute '${attr.name}' is only valid at session level, not media level`, 'RFC8866:6'));
383
+ }
384
+ if (attr.kind === 'value' && attr.name === 'rtpmap') {
385
+ const match = attr.value.match(/^(\d+)\s/);
386
+ if (match) {
387
+ const payloadType = match[1];
388
+ if (rtpmapPayloadTypes.has(payloadType)) {
389
+ rtpmapPayloadTypeDuplicates.add(payloadType);
390
+ }
391
+ else {
392
+ rtpmapPayloadTypes.add(payloadType);
393
+ }
394
+ }
395
+ }
396
+ if (attr.kind === 'value' && attr.name === 'fmtp') {
397
+ const match = attr.value.match(/^(\S+)\s/);
398
+ if (match) {
399
+ const format = match[1];
400
+ if (fmtpFormats.has(format)) {
401
+ fmtpFormatDuplicates.add(format);
402
+ }
403
+ else {
404
+ fmtpFormats.add(format);
405
+ }
406
+ }
407
+ }
408
+ if (attr.kind === 'value' && attr.name === 'ptime') {
409
+ const ptime = parseFloat(attr.value);
410
+ if (isNaN(ptime) || ptime <= 0) {
411
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.attributes.ptime`, attr.value, 'ptime must be a positive number', 'RFC8866:6.4'));
412
+ }
413
+ }
414
+ if (attr.kind === 'value' && attr.name === 'maxptime') {
415
+ const maxptime = parseFloat(attr.value);
416
+ if (isNaN(maxptime) || maxptime <= 0) {
417
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.attributes.maxptime`, attr.value, 'maxptime must be a positive number', 'RFC8866:6.5'));
418
+ }
419
+ }
420
+ if (attr.kind === 'value' && attr.name === 'quality') {
421
+ const quality = parseInt(attr.value, 10);
422
+ if (isNaN(quality) || quality < 0 || quality > 10) {
423
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.attributes.quality`, attr.value, 'quality must be 0-10', 'RFC8866:6.14'));
424
+ }
425
+ }
426
+ if (attr.kind === 'value' && attr.name === 'framerate') {
427
+ const framerate = parseFloat(attr.value);
428
+ if (isNaN(framerate) || framerate <= 0) {
429
+ errors.push(errors_1.ValidationError.invalidValue(`${prefix}.attributes.framerate`, attr.value, 'framerate must be a positive number', 'RFC8866:6.13'));
430
+ }
431
+ }
432
+ }
433
+ if (directionCount > 1) {
434
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: only one direction attribute (sendrecv/sendonly/recvonly/inactive) allowed`, 'RFC8866:6.7'));
435
+ }
436
+ for (const pt of rtpmapPayloadTypeDuplicates) {
437
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: duplicate rtpmap for payload type ${pt} (at most one rtpmap per format allowed)`, 'RFC8866:6.6'));
438
+ }
439
+ for (const fmt of fmtpFormatDuplicates) {
440
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: duplicate fmtp for format ${fmt} (at most one fmtp per format allowed)`, 'RFC8866:6.15'));
441
+ }
442
+ const formatStrings = media.media.formats.map((f) => f);
443
+ for (const pt of rtpmapPayloadTypes) {
444
+ if (!formatStrings.includes(pt)) {
445
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: rtpmap payload type ${pt} not in format list`, 'RFC8866:6.6'));
446
+ }
447
+ }
448
+ for (const fmt of fmtpFormats) {
449
+ if (!formatStrings.includes(fmt) && !rtpmapPayloadTypes.has(fmt)) {
450
+ errors.push(errors_1.ValidationError.constraintViolation(`${prefix}: fmtp format ${fmt} not in format list`, 'RFC8866:6.15'));
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Validate all media descriptions in a session
456
+ *
457
+ * @param mediaDescriptions - Array of media descriptions
458
+ * @param hasSessionConnection - Whether session-level connection exists
459
+ * @returns ValidationResult with any errors found
460
+ */
461
+ function validateAllMediaDescriptions(mediaDescriptions, hasSessionConnection) {
462
+ const allErrors = [];
463
+ for (let i = 0; i < mediaDescriptions.length; i++) {
464
+ const result = validateMediaDescription(mediaDescriptions[i], i, hasSessionConnection);
465
+ allErrors.push(...result.errors);
466
+ }
467
+ return allErrors.length === 0 ? validResult() : invalidResult(allErrors);
468
+ }
469
+ // ============================================================================
470
+ // Semantic Validation
471
+ // ============================================================================
472
+ /**
473
+ * Validate cross-field semantic constraints
474
+ *
475
+ * @param session - Session description to validate
476
+ * @returns ValidationResult with any errors found
477
+ */
478
+ function validateSemanticConstraints(session) {
479
+ const errors = [];
480
+ validateDirectionConsistency(session, errors);
481
+ validateBandwidthConsistency(session, errors);
482
+ validateAddressTypeConsistency(session, errors);
483
+ validatePtimeConsistency(session, errors);
484
+ validateTimeConsistency(session, errors);
485
+ return errors.length === 0 ? validResult() : invalidResult(errors);
486
+ }
487
+ /**
488
+ * Validate direction attribute consistency
489
+ */
490
+ function validateDirectionConsistency(session, errors) {
491
+ const directionAttrs = ['sendrecv', 'sendonly', 'recvonly', 'inactive'];
492
+ let sessionDirection;
493
+ for (const attr of session.attributes) {
494
+ if (attr.kind === 'property' && directionAttrs.includes(attr.name)) {
495
+ sessionDirection = attr.name;
496
+ break;
497
+ }
498
+ }
499
+ // If session is inactive, we could warn if any media has active direction
500
+ // but RFC allows media to override session-level direction
501
+ }
502
+ /**
503
+ * Validate bandwidth consistency
504
+ */
505
+ function validateBandwidthConsistency(session, errors) {
506
+ let sessionCT;
507
+ for (const bw of session.bandwidths) {
508
+ if (bw.type === 'CT') {
509
+ sessionCT = bw.value;
510
+ break;
511
+ }
512
+ }
513
+ if (sessionCT === undefined) {
514
+ return;
515
+ }
516
+ let totalAS = 0;
517
+ for (const md of session.mediaDescriptions) {
518
+ for (const bw of md.bandwidths) {
519
+ if (bw.type === 'AS') {
520
+ totalAS += bw.value;
521
+ }
522
+ }
523
+ }
524
+ for (const bw of session.bandwidths) {
525
+ if (bw.type === 'AS') {
526
+ totalAS += bw.value;
527
+ }
528
+ }
529
+ if (totalAS > sessionCT) {
530
+ errors.push(errors_1.ValidationError.constraintViolation(`Sum of AS bandwidths (${totalAS}) exceeds CT bandwidth (${sessionCT})`, 'RFC8866:5.8'));
531
+ }
532
+ }
533
+ /**
534
+ * Validate address type consistency
535
+ */
536
+ function validateAddressTypeConsistency(session, errors) {
537
+ // Different address types are allowed per RFC
538
+ // This function is a placeholder for additional consistency checks if needed
539
+ }
540
+ /**
541
+ * Validate ptime/maxptime relationship
542
+ */
543
+ function validatePtimeConsistency(session, errors) {
544
+ for (let i = 0; i < session.mediaDescriptions.length; i++) {
545
+ const md = session.mediaDescriptions[i];
546
+ let ptime;
547
+ let maxptime;
548
+ for (const attr of md.attributes) {
549
+ if (attr.kind === 'value') {
550
+ if (attr.name === 'ptime') {
551
+ ptime = parseFloat(attr.value);
552
+ }
553
+ else if (attr.name === 'maxptime') {
554
+ maxptime = parseFloat(attr.value);
555
+ }
556
+ }
557
+ }
558
+ if (ptime !== undefined && maxptime !== undefined && ptime > maxptime) {
559
+ errors.push(errors_1.ValidationError.constraintViolation(`Media ${i}: ptime (${ptime}) exceeds maxptime (${maxptime})`, 'RFC8866:6.4-6.5'));
560
+ }
561
+ }
562
+ }
563
+ /**
564
+ * Validate time description constraints
565
+ */
566
+ function validateTimeConsistency(session, errors) {
567
+ for (let i = 0; i < session.timeDescriptions.length; i++) {
568
+ const td = session.timeDescriptions[i];
569
+ if (td.timezone && td.repeats.length === 0) {
570
+ errors.push(errors_1.ValidationError.constraintViolation(`Time description ${i}: timezone adjustment specified without repeat times`, 'RFC8866:5.11'));
571
+ }
572
+ }
573
+ }