@blockcast/mmt-container 1.0.1 → 2.0.0-main.609e08d7e29f

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 (101) hide show
  1. package/README.md +9 -7
  2. package/dist/browser-runtime.js +6972 -6693
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.d.ts +7 -7
  5. package/dist/carp-catalog.d.ts.map +1 -1
  6. package/dist/carp-catalog.js +65 -230
  7. package/dist/carp-catalog.js.map +3 -3
  8. package/dist/catalog-fec-params.js.map +1 -1
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/delivery-catalog.js +3 -0
  11. package/dist/delivery-catalog.js.map +2 -2
  12. package/dist/delta-fallback-config.js.map +1 -1
  13. package/dist/fec-manager.d.ts +25 -1
  14. package/dist/fec-manager.d.ts.map +1 -1
  15. package/dist/fec-manager.js +208 -15
  16. package/dist/fec-manager.js.map +2 -2
  17. package/dist/fec-state.js.map +1 -1
  18. package/dist/fec-worker-setup.js.map +1 -1
  19. package/dist/held-layer-admission.d.ts +8 -0
  20. package/dist/held-layer-admission.d.ts.map +1 -1
  21. package/dist/held-layer-admission.js +22 -1
  22. package/dist/held-layer-admission.js.map +2 -2
  23. package/dist/iso-block-locator.js.map +1 -1
  24. package/dist/mfu-reassembler.js.map +1 -1
  25. package/dist/mmtp-container-parser.js.map +1 -1
  26. package/dist/mmtp-container-parser.no-modules.js +146 -332
  27. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  28. package/dist/mmtp-decode-pipeline.js.map +1 -1
  29. package/dist/mmtp-fec-session.d.ts.map +1 -1
  30. package/dist/mmtp-fec-session.js +61 -15
  31. package/dist/mmtp-fec-session.js.map +3 -3
  32. package/dist/mmtp-router.d.ts +11 -6
  33. package/dist/mmtp-router.d.ts.map +1 -1
  34. package/dist/mmtp-router.js +47 -10
  35. package/dist/mmtp-router.js.map +3 -3
  36. package/dist/multicast-auth-catalog.d.ts +8 -8
  37. package/dist/multicast-auth-catalog.d.ts.map +1 -1
  38. package/dist/multicast-auth-catalog.js +171 -75
  39. package/dist/multicast-auth-catalog.js.map +4 -4
  40. package/dist/multicast-input.d.ts +5 -5
  41. package/dist/multicast-input.d.ts.map +1 -1
  42. package/dist/multicast-input.js +126 -49
  43. package/dist/multicast-input.js.map +3 -3
  44. package/dist/multicast-manager.d.ts +11 -4
  45. package/dist/multicast-manager.d.ts.map +1 -1
  46. package/dist/multicast-manager.js +7914 -7659
  47. package/dist/multicast-manager.js.map +4 -4
  48. package/dist/multicast-transport.d.ts +16 -1
  49. package/dist/multicast-transport.d.ts.map +1 -1
  50. package/dist/multicast-transport.js +120 -5
  51. package/dist/multicast-transport.js.map +4 -4
  52. package/dist/multicast-unwrap.js +2 -2
  53. package/dist/multicast-unwrap.js.map +2 -2
  54. package/dist/no-modules.js +100 -288
  55. package/dist/no-modules.js.map +4 -4
  56. package/dist/repair-fetch.js.map +1 -1
  57. package/dist/signaling.js.map +1 -1
  58. package/dist/timestamp-codec.js.map +1 -1
  59. package/dist/wasm-mfu-reassembler.js.map +1 -1
  60. package/dist/wasm-mmtp-container-parser.js.map +1 -1
  61. package/package.json +5 -5
  62. package/schemas/catalog.schema.json +15 -7
  63. package/src/carp-catalog.ts +14 -13
  64. package/src/fec-manager.ts +89 -14
  65. package/src/held-layer-admission.ts +38 -1
  66. package/src/mmtp-fec-session.ts +20 -12
  67. package/src/mmtp-router.ts +65 -16
  68. package/src/multicast-auth-catalog.ts +43 -36
  69. package/src/multicast-input.ts +28 -8
  70. package/src/multicast-manager.ts +38 -34
  71. package/src/multicast-transport.ts +74 -7
  72. package/test-vectors/bc-provenance-v2.json +614 -175
  73. package/test-vectors/catalog/README.md +24 -8
  74. package/test-vectors/catalog/negative/multicast-auth-asm-missing-sender-address.json +0 -2
  75. package/test-vectors/catalog/negative/{multicast-auth-manifest-port-equals-port.json → multicast-auth-draft01-manifest-port.json} +1 -1
  76. package/test-vectors/catalog/negative/multicast-auth-duplicate-first-epoch.json +0 -1
  77. package/test-vectors/catalog/negative/multicast-auth-duplicate-kid.json +0 -1
  78. package/test-vectors/catalog/negative/multicast-auth-endpoint-auth-without-object-auth.json +0 -1
  79. package/test-vectors/catalog/negative/multicast-auth-endpoint-missing-bandwidth.json +0 -1
  80. package/test-vectors/catalog/negative/multicast-auth-manifest-timer-out-of-range.json +0 -1
  81. package/test-vectors/catalog/negative/multicast-auth-sender-address-on-ssm.json +0 -1
  82. package/test-vectors/catalog/negative/multicast-auth-shared-sg-port.json +73 -0
  83. package/test-vectors/catalog/negative/multicast-auth-version-1.json +0 -1
  84. package/test-vectors/catalog/negative/multicast-auth-version-3.json +0 -1
  85. package/test-vectors/catalog/negative/multicast-auth-version-missing.json +0 -1
  86. package/test-vectors/catalog/negative/multicast-endpoint-address-family-mismatch.json +55 -0
  87. package/test-vectors/catalog/negative/multicast-endpoint-address-host-name.json +55 -0
  88. package/test-vectors/catalog/negative/multicast-endpoint-address-leading-zero.json +55 -0
  89. package/test-vectors/catalog/negative/multicast-endpoint-address-ls32-leading-zero.json +55 -0
  90. package/test-vectors/catalog/negative/multicast-endpoint-address-mapped-family-mismatch.json +55 -0
  91. package/test-vectors/catalog/negative/multicast-endpoint-address-zone-id.json +55 -0
  92. package/test-vectors/catalog/positive/multicast-auth-asm-sender.json +0 -2
  93. package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-above-max.json → positive/multicast-auth-max-auth-delay-above-max-clamped.json} +0 -1
  94. package/test-vectors/catalog/{negative/multicast-auth-max-auth-delay-below-min.json → positive/multicast-auth-max-auth-delay-below-min-clamped.json} +0 -1
  95. package/test-vectors/catalog/positive/multicast-auth-max-auth-delay-string.json +55 -0
  96. package/test-vectors/catalog/positive/multicast-auth-rotation.json +0 -1
  97. package/test-vectors/catalog/positive/multicast-draft01-7-2-6-example.json +0 -1
  98. package/test-vectors/catalog/section-codes.json +8 -3
  99. package/test-vectors/catalog-multicast-auth-v1.json +0 -1
  100. package/test-vectors/catalog-validation.json +3 -3
  101. package/test-vectors/generate-bc-provenance-v2.mjs +94 -14
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `multicast.auth` and an endpoint's `auth`: bc-provenance version 2, per
3
- * draft-ramadan-moq-multicast-01 7.2.6 at
4
- * Blockcast/moqcast-draft@fd962fd73d21ed281b60219c11ea61cc9e4e91c4.
3
+ * draft-ramadan-moq-multicast-02 7.2.6 at Blockcast/moqcast-draft@5f49172.
4
+ * Records ride in band on the flow's own `port` (7.2.3); draft -01's separate
5
+ * record port is gone.
5
6
  *
6
7
  * The block lives in the `multicast` object, never the root: the root dotted
7
8
  * key `"multicast.auth"` was draft -00's (libmmt called its `tracks[]` shape
@@ -15,7 +16,13 @@
15
16
  * the transport receivers; this module decodes what they admit.
16
17
  */
17
18
 
18
- import { multicastCatalogIssues } from "@blockcast/mmt-base";
19
+ import {
20
+ BCP_DEFAULT_MANIFEST_TIMER_MS,
21
+ BCP_DEFAULT_MAX_AUTH_DELAY_MS,
22
+ canonicalIpAddress,
23
+ endpointAuthTiming,
24
+ multicastCatalogIssues,
25
+ } from "@blockcast/mmt-base";
19
26
  import { bcProvenanceV2Ctx, type BcProvenanceV2Params, type BcpV2Key } from "@blockcast/mmt-manifest-verify";
20
27
 
21
28
  export type MulticastAuthScheme = "bc-provenance";
@@ -24,8 +31,8 @@ export type MulticastAuthSig = "ed25519";
24
31
  const RAW_ED25519_KEY_LENGTH = 32;
25
32
 
26
33
  /** Default `maxAuthDelayMs` and `manifestTimerMs` of an endpoint's `auth`. */
27
- export const DEFAULT_MAX_AUTH_DELAY_MS = 200;
28
- export const DEFAULT_MANIFEST_TIMER_MS = 20;
34
+ export const DEFAULT_MAX_AUTH_DELAY_MS = BCP_DEFAULT_MAX_AUTH_DELAY_MS;
35
+ export const DEFAULT_MANIFEST_TIMER_MS = BCP_DEFAULT_MANIFEST_TIMER_MS;
29
36
 
30
37
  const BASE64URL_ALPHABET =
31
38
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
@@ -118,15 +125,13 @@ export interface MulticastAuthConfig {
118
125
 
119
126
  /** An endpoint's `auth` (7.2.6). A protected endpoint also declares `bandwidth`. */
120
127
  export interface EndpointAuthConfig {
121
- /** Destination port of the record datagrams on the endpoint's `(S, G)`. */
122
- manifestPort: number;
123
128
  /** u32 chosen per channel generation. */
124
129
  streamId: number;
125
130
  /** `S` of a protected ASM endpoint; never on SSM. */
126
131
  senderAddress?: string;
127
- /** 50..1000, default 200. */
132
+ /** 50..1000, default 200; a receiver clamps one outside the range. */
128
133
  maxAuthDelayMs?: number;
129
- /** At least 1, default 20. */
134
+ /** At least 1, default 20; a receiver ignores the endpoint below 1. */
130
135
  manifestTimerMs?: number;
131
136
  /** MoQ track mirroring the records. */
132
137
  manifestTrack?: string;
@@ -142,12 +147,13 @@ export function isMulticastAuthConfig(obj: unknown): obj is MulticastAuthConfig
142
147
  export interface ResolvedMulticastAuthFlow {
143
148
  /** Index of the endpoint in `multicast.endpoints`. */
144
149
  endpointIndex: number;
145
- /** `S`: `sourceAddress` on SSM, `auth.senderAddress` on ASM. */
150
+ /** `S`: `sourceAddress` on SSM, `auth.senderAddress` on ASM; canonical (`canonicalIpAddress`). */
146
151
  source: string;
152
+ /** Canonical (`canonicalIpAddress`). */
147
153
  groupAddress: string;
154
+ /** The flow's port; its records ride in band on it (7.2.3). */
148
155
  port: number;
149
156
  bandwidth: number;
150
- manifestPort: number;
151
157
  streamId: number;
152
158
  maxAuthDelayMs: number;
153
159
  manifestTimerMs: number;
@@ -221,15 +227,13 @@ export function resolveMulticastAuthCatalog(
221
227
  }
222
228
  flows.push({
223
229
  endpointIndex,
224
- source: (endpoint["sourceAddress"] ?? flowAuth.senderAddress) as string,
225
- groupAddress: endpoint["groupAddress"] as string,
230
+ source: canonicalIpAddress(endpoint["sourceAddress"] ?? flowAuth.senderAddress)!,
231
+ groupAddress: canonicalIpAddress(endpoint["groupAddress"])!,
226
232
  port: endpoint["port"] as number,
227
233
  bandwidth: endpoint["bandwidth"] as number,
228
- manifestPort: flowAuth.manifestPort,
229
234
  streamId: flowAuth.streamId,
230
- maxAuthDelayMs: flowAuth.maxAuthDelayMs ?? DEFAULT_MAX_AUTH_DELAY_MS,
231
- manifestTimerMs: flowAuth.manifestTimerMs ?? DEFAULT_MANIFEST_TIMER_MS,
232
- ...(flowAuth.manifestTrack === undefined ? {} : { manifestTrack: flowAuth.manifestTrack }),
235
+ // Clamped, or the defaults where absent (7.2.6).
236
+ ...endpointAuthTiming(flowAuth),
233
237
  tracks,
234
238
  ...(ctx === undefined ? {} : { ctx }),
235
239
  });
@@ -249,46 +253,49 @@ function positiveInteger(value: unknown): number {
249
253
  }
250
254
 
251
255
  /**
252
- * `params_hash` inputs (7.2.4): the catalog entry of the endpoint's
253
- * lowest-`packetId` media track or, for a repair-only endpoint, of the source
254
- * its lowest-`packetId` repair track repairs (`depends`, else the track whose
255
- * `fec.repairTrack` names it). A timescale or group duration that does not
256
- * resolve to an integer tick count is `0`.
256
+ * `params_hash` inputs (multicast-02 7.2.4): the catalog entry of the
257
+ * endpoint's lowest-`packetId` media track or, for a repair-only endpoint, of
258
+ * the source its lowest-`packetId` repair track repairs (its `source`, else
259
+ * the track one of whose `fec` instances names it as `repairTrack`). A
260
+ * timescale or group duration that does not resolve to an integer tick count
261
+ * is `0`. An instance naming the reserved scheme "none" (fec-core-00 15.2)
262
+ * has no params_hash, so its endpoint is ignored like one with no entry.
257
263
  */
258
264
  function ctxParams(
259
265
  catalogTracks: Array<Record<string, unknown>>,
260
266
  listed: Array<{ name: string; packetId: number }>,
261
267
  ): BcProvenanceV2Params | undefined {
262
268
  const byName = new Map(catalogTracks.map((track) => [track["name"], track]));
269
+ const instancesOf = (track: Record<string, unknown>): Array<Record<string, unknown>> =>
270
+ Array.isArray(track["fec"]) ? (track["fec"] as unknown[]).filter((f): f is Record<string, unknown> => typeof f === "object" && f !== null) : [];
263
271
  const ordered = [...listed].sort((a, b) => a.packetId - b.packetId).map(({ name }) => byName.get(name));
264
272
  let entry = ordered.find((track) => track !== undefined && track["packaging"] !== "fec-repair");
265
273
  if (entry === undefined) {
266
274
  const repair = ordered.find((track) => track !== undefined);
267
275
  if (repair === undefined) return undefined;
268
- const depends = repair["depends"];
269
- entry = Array.isArray(depends) && depends.length === 1
270
- ? byName.get(depends[0])
271
- : catalogTracks.find((track) => (track["fec"] as Record<string, unknown> | undefined)?.["repairTrack"] === repair["name"]);
276
+ entry = typeof repair["source"] === "string"
277
+ ? byName.get(repair["source"])
278
+ : catalogTracks.find((track) => instancesOf(track).some((f) => f["repairTrack"] === repair["name"]));
272
279
  if (entry === undefined) return undefined;
273
280
  }
281
+ const instances = instancesOf(entry);
282
+ if (instances.some((f) => f["scheme"] === "none")) return undefined;
274
283
  const timescale = positiveInteger(entry["timescale"]);
275
284
  const ms = positiveInteger(entry["groupDurationMs"]);
276
285
  const groupDurationTicks = positiveInteger(entry["groupDurationTicks"]) ||
277
286
  positiveInteger(timescale && (ms * timescale) % 1000 === 0 ? (ms * timescale) / 1000 : 0);
278
- const fec = entry["fec"] as Record<string, unknown> | undefined;
279
287
  return {
280
288
  codec: typeof entry["codec"] === "string" ? entry["codec"] : "",
281
289
  timescale,
282
290
  groupDurationTicks,
283
- ...(fec === undefined || fec === null ? {} : {
284
- fec: {
285
- algorithm: typeof fec["algorithm"] === "string" ? fec["algorithm"] : "",
286
- sourceSymbols: positiveInteger(fec["sourceSymbols"]),
287
- repairSymbols: positiveInteger(fec["repairSymbols"]),
288
- symbolSize: positiveInteger(fec["symbolSize"]),
289
- interleaveDepthMs: positiveInteger(fec["interleaveDepthMs"]),
290
- },
291
- }),
291
+ fec: instances.map((f) => ({
292
+ instanceId: typeof f["instanceId"] === "number" ? f["instanceId"] : 0,
293
+ scheme: typeof f["scheme"] === "string" ? f["scheme"] : "",
294
+ sourceSymbols: positiveInteger(f["sourceSymbols"]),
295
+ repairSymbols: positiveInteger(f["repairSymbols"]),
296
+ symbolSize: positiveInteger(f["symbolSize"]),
297
+ interleaveDepthMs: positiveInteger(f["interleaveDepthMs"]),
298
+ })),
292
299
  };
293
300
  }
294
301
 
@@ -36,10 +36,13 @@ declare const process: { env: Record<string, string | undefined> } | undefined;
36
36
  * per-endpoint `relayAddress` input is LIFTED into the AMT entry.
37
37
  * - Native "ssm" | "asm" requires no `networkSource` entry: a native join
38
38
  * describes no alternate bearer (BLO-37926).
39
- * - `auth` (catalog) and endpoint `auth` follow draft -01 7.2.6 through the
39
+ * - `auth` (catalog) and endpoint `auth` follow draft -02 7.2.6 through the
40
40
  * shared `multicastCatalogIssues` of `@blockcast/mmt-base`: all or
41
- * nothing, `senderAddress` on ASM only, bandwidth required, and
42
- * `(S, G, manifestPort)` clear of every port of its `(S, G)`.
41
+ * nothing, `senderAddress` on ASM only, bandwidth required, addresses
42
+ * RFC 3986 IP literals, and one endpoint per `(S, G, port)` (the flow's
43
+ * records ride in band on its `port`, 7.2.3). A receiver clamps a
44
+ * `maxAuthDelayMs` outside 50..1000; this emitter never writes one, nor a
45
+ * non-integer timer or a non-string `manifestTrack`.
43
46
  * - groupAddress is a valid IPv4 multicast group (224.0.0.0/4)
44
47
  * or IPv6 multicast group (ff00::/8)
45
48
  * - SSM groupAddress MUST be in 232.0.0.0/8 (IPv4) or ff3x::/32 (IPv6),
@@ -105,7 +108,7 @@ export interface MulticastEndpointInput {
105
108
  readonly relayAddress?: string;
106
109
  /** Aggregate bandwidth in bits per second. RECOMMENDED per draft §4.1; REQUIRED with `auth`. */
107
110
  readonly bandwidth?: number;
108
- /** Protected-flow parameters (draft -01 7.2.6); REQUIRED exactly when `MulticastInput.auth` is set. */
111
+ /** Protected-flow parameters (draft -02 7.2.6); REQUIRED exactly when `MulticastInput.auth` is set. */
109
112
  readonly auth?: EndpointAuthConfig;
110
113
  }
111
114
 
@@ -148,13 +151,13 @@ export type MulticastInputErrorKind =
148
151
  | "ssm_requires_source"
149
152
  /** Input `protocol` "asm" with a `sourceAddress`, which selects SSM. */
150
153
  | "protocol_source_mismatch"
151
- /** `MulticastInput.auth` fails draft -01 7.2.6 (version, keys, a repeated kid or firstEpoch). */
154
+ /** `MulticastInput.auth` fails draft -02 7.2.6 (version, keys, a repeated kid or firstEpoch). */
152
155
  | "invalid_multicast_auth"
153
156
  /**
154
- * An endpoint's `auth` fails draft -01 7.2.6: present without
157
+ * An endpoint's `auth` fails draft -02 7.2.6: present without
155
158
  * `MulticastInput.auth` or absent with it, `senderAddress` on SSM or
156
- * missing on ASM, a timer out of range, no `bandwidth`, or a
157
- * `manifestPort` that is a port or manifestPort of its `(S, G)`.
159
+ * missing on ASM, a timer out of range or not an integer, a non-string
160
+ * `manifestTrack`, or no `bandwidth`.
158
161
  */
159
162
  | "invalid_endpoint_auth"
160
163
  /**
@@ -390,6 +393,23 @@ export function multicastFromConfig(input: MulticastInput): MulticastCatalogConf
390
393
  const where = issue.endpointIndex === undefined ? "" : `endpoint[${issue.endpointIndex}] `;
391
394
  throw new MulticastInputError(kind, issue.endpointIndex ?? null, `${where}${issue.path.replace("$.multicast.", "")}: ${issue.message}`);
392
395
  }
396
+ // What a receiver clamps or reads as absent (7.2.6) a publisher still never emits.
397
+ endpoints.forEach((ep, idx) => {
398
+ const auth = (ep as { auth?: Record<string, unknown> }).auth;
399
+ if (auth === undefined) return;
400
+ const delay = auth["maxAuthDelayMs"];
401
+ const timer = auth["manifestTimerMs"];
402
+ const track = auth["manifestTrack"];
403
+ const bad =
404
+ delay !== undefined && !(Number.isSafeInteger(delay) && (delay as number) >= 50 && (delay as number) <= 1000)
405
+ ? "maxAuthDelayMs must be an integer from 50 to 1000"
406
+ : timer !== undefined && !(Number.isSafeInteger(timer) && (timer as number) >= 1)
407
+ ? "manifestTimerMs must be an integer of at least 1"
408
+ : track !== undefined && !(typeof track === "string" && track.length > 0)
409
+ ? "manifestTrack must be a non-empty string"
410
+ : undefined;
411
+ if (bad) throw new MulticastInputError("invalid_endpoint_auth", idx, `endpoint[${idx}] auth: ${bad}`);
412
+ });
393
413
 
394
414
  if (input.enabled !== undefined) out.enabled = input.enabled;
395
415
  if (input.version !== undefined) out.version = input.version;
@@ -8,7 +8,7 @@
8
8
  * - MulticastTransport — SSM join/socket/watchdog
9
9
  */
10
10
 
11
- import { parseFecBlockProfile, resolveFecBindings, type FecBlockProfile, type FecSourceBinding } from "@blockcast/mmt-base";
11
+ import { canonicalIpAddress, parseFecBlockProfile, resolveFecBindings, sameIpAddress, type FecBlockProfile, type FecSourceBinding } from "@blockcast/mmt-base";
12
12
  import {
13
13
  FECManager,
14
14
  MIN_INTERLEAVE_DEPTH_MS,
@@ -117,10 +117,11 @@ export type MulticastRepairSubscribe = (
117
117
  ) => (() => void) | Promise<() => void>;
118
118
 
119
119
  /**
120
- * A protected endpoint's join (draft -01 7.2.7): the keys and flow
120
+ * A protected endpoint's join (draft -02 7.2.7): the keys and flow
121
121
  * `resolveMulticastAuthCatalog` resolved for it. The join registers the flow
122
- * with the router and subscribes `manifestPort` on the same `(S, G)` beside
123
- * the media, so every media datagram is admitted by a record before it is read.
122
+ * with the router; its records ride in band on the media `port` (7.2.3) and
123
+ * the router splits them off, so every media datagram is admitted by a record
124
+ * before it is read. No second subscription is opened.
124
125
  */
125
126
  export interface MulticastProvenanceJoin {
126
127
  auth: Pick<ResolvedMulticastAuthConfig, "keys" | "minEpoch">;
@@ -1269,8 +1270,8 @@ export class MulticastManager {
1269
1270
  readonly #router: MmtpRouter;
1270
1271
  #hostMfuTimeoutMs: number | null;
1271
1272
  readonly #transport: MulticastTransport<MmtpIngressContext>;
1272
- /** The protected flow of the current join and its manifestPort subscription. */
1273
- #provenanceJoin: { flow: string; manifest: MulticastTransport } | null = null;
1273
+ /** The protected flow of the current join. */
1274
+ #provenanceJoin: { flow: string } | null = null;
1274
1275
  #onFailback: ((reason: MulticastFailbackReason) => void) | null = null;
1275
1276
  #failbackHooked = false;
1276
1277
  /** Live joinRepairEndpoints() releases → the repair owners they registered,
@@ -2127,10 +2128,11 @@ export class MulticastManager {
2127
2128
 
2128
2129
  async join(config: MulticastEndpointConfig): Promise<boolean> {
2129
2130
  const provenanceFlow = config.provenance?.flow;
2131
+ // Addresses compare as IP addresses, never as strings (multicast-02 7.2.6).
2130
2132
  if (
2131
2133
  provenanceFlow !== undefined &&
2132
- ((config.sourceAddress ?? config.senderAddress) !== provenanceFlow.source ||
2133
- config.groupAddress !== provenanceFlow.groupAddress ||
2134
+ (!sameIpAddress(config.sourceAddress ?? config.senderAddress, provenanceFlow.source) ||
2135
+ !sameIpAddress(config.groupAddress, provenanceFlow.groupAddress) ||
2134
2136
  config.port !== provenanceFlow.port)
2135
2137
  ) {
2136
2138
  throw new RangeError(
@@ -2210,28 +2212,26 @@ export class MulticastManager {
2210
2212
  amtDiscovery: config.amtDiscovery,
2211
2213
  packetIds: config.packetIds,
2212
2214
  tracks: catalogTracks?.map(({ name, packetId }) => ({ name, packetId })),
2215
+ ...(provenance === undefined ? {} : { protected: true }),
2213
2216
  };
2214
- if (provenance !== undefined) this.#joinProvenance(provenance, ingress as MmtpIngressContext & { flow: string }, transportConfig);
2217
+ if (provenance !== undefined) this.#joinProvenance(provenance, ingress as MmtpIngressContext & { flow: string });
2215
2218
  const protectedJoin = this.#provenanceJoin;
2216
2219
  const joined = await (ingress === undefined
2217
2220
  ? this.#transport.join(transportConfig)
2218
2221
  : this.#transport.join(transportConfig, ingress));
2219
- // No media: leave the manifest subscription and release the flow now, not at the next join.
2222
+ // No media: release the flow now, not at the next join.
2220
2223
  if (!joined && protectedJoin !== null && this.#provenanceJoin === protectedJoin) this.#releaseProvenanceJoin();
2221
2224
  return joined;
2222
2225
  }
2223
2226
 
2224
2227
  /**
2225
- * Register the protected flow and subscribe its `manifestPort` on the same
2226
- * `(S, G)` with its own listener (draft -01 7.2.7), so records reach the
2227
- * flow's receiver and media datagrams are held until one admits them. A
2228
- * manifest subscription that fails to join admits nothing: the media is
2229
- * held and expires, never read unauthenticated.
2228
+ * Register the protected flow (draft -02 7.2.7). Its records arrive in band
2229
+ * on the media join (7.2.3), where the router splits them off on "MHM2" and
2230
+ * feeds the flow's receiver; media datagrams are held until one admits them.
2230
2231
  */
2231
2232
  #joinProvenance(
2232
2233
  provenance: MulticastProvenanceJoin,
2233
2234
  ingress: MmtpIngressContext & { flow: string },
2234
- media: MulticastTransportConfig,
2235
2235
  ): void {
2236
2236
  const { flow, auth } = provenance;
2237
2237
  const receiver = new BcProvenanceV2Receiver({
@@ -2243,7 +2243,6 @@ export class MulticastManager {
2243
2243
  sourceAddress: flow.source,
2244
2244
  groupAddress: flow.groupAddress,
2245
2245
  port: flow.port,
2246
- manifestPort: flow.manifestPort,
2247
2246
  streamId: flow.streamId,
2248
2247
  },
2249
2248
  keys: auth.keys,
@@ -2254,26 +2253,14 @@ export class MulticastManager {
2254
2253
  });
2255
2254
  this.#router.registerProvenanceFlow(ingress, receiver);
2256
2255
  this.#hookFailback();
2257
- const manifest = new MulticastTransport((record) => {
2258
- if (this.#provenanceJoin?.manifest === manifest) {
2259
- this.#router.feedProvenanceRecord(ingress.flow, record, "multicast");
2260
- }
2261
- });
2262
- this.#provenanceJoin = { flow: ingress.flow, manifest };
2263
- const { packetIds: _packetIds, tracks: _tracks, ...path } = media;
2264
- void manifest.join({ ...path, port: flow.manifestPort }).then((joined) => {
2265
- if (!joined && this.#provenanceJoin?.manifest === manifest) {
2266
- console.warn(`[MulticastManager] manifestPort ${flow.groupAddress}:${flow.manifestPort} not joined`, manifest.lastJoinOutcome);
2267
- }
2268
- });
2256
+ this.#provenanceJoin = { flow: ingress.flow };
2269
2257
  }
2270
2258
 
2271
- /** Un-protect: drop the flow's hold queues (no expiry counted) and leave its manifestPort. */
2259
+ /** Un-protect: drop the flow's hold queues (no expiry counted). */
2272
2260
  #releaseProvenanceJoin(): void {
2273
2261
  const joined = this.#provenanceJoin;
2274
2262
  if (joined === null) return;
2275
2263
  this.#provenanceJoin = null;
2276
- joined.manifest.dispose();
2277
2264
  this.#router.releaseProvenanceFlow(joined.flow);
2278
2265
  }
2279
2266
 
@@ -2316,7 +2303,7 @@ export class MulticastManager {
2316
2303
  if (released) return;
2317
2304
  // The media join's sender owns the repair too; this group's own first
2318
2305
  // sender stands in only until the media join has one.
2319
- if (sourceAddress !== undefined && sourceAddress !== (this.#transport.sender ?? (sender ??= sourceAddress))) return;
2306
+ if (sourceAddress !== undefined && !sameDatagramSource(sourceAddress, this.#transport.sender ?? (sender ??= sourceAddress))) return;
2320
2307
  this.#router.feedPacket(packet, "multicast", ingress);
2321
2308
  };
2322
2309
  const fail = (error: unknown) =>
@@ -2341,6 +2328,8 @@ export class MulticastManager {
2341
2328
  amtRelayPort: endpoint.amtRelayPort ?? (endpoint.amtRelay?.trim() ? DEFAULT_AMT_RELAY_PORT : undefined),
2342
2329
  packetIds: endpoint.packetIds,
2343
2330
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
2331
+ // A protected repair group's records ride in band on its port (7.2.3).
2332
+ ...(endpoint.flow === undefined ? {} : { protected: true }),
2344
2333
  }).then((joined) => {
2345
2334
  if (!joined && !released) fail(transport.lastJoinOutcome);
2346
2335
  });
@@ -3395,7 +3384,22 @@ export class MulticastManager {
3395
3384
  const repairFetchHost = (manager: MulticastManager): RepairFetchHost => manager;
3396
3385
  void repairFetchHost;
3397
3386
 
3398
- /** The router's key of a protected flow: its `(S, G, port)`. */
3387
+ /**
3388
+ * The router's key of a protected flow: its `(S, G, port)`, with S and G in
3389
+ * canonical form (`canonicalIpAddress`), so two spellings of one address key
3390
+ * one flow (multicast-02 7.2.6). Resolved flows already carry canonical
3391
+ * addresses, so `JSON.stringify([source, groupAddress, port])` of one is this
3392
+ * key; text that is not an address literal is kept as given.
3393
+ */
3399
3394
  export function multicastProvenanceFlowKey(flow: Pick<ResolvedMulticastAuthFlow, "source" | "groupAddress" | "port">): string {
3400
- return JSON.stringify([flow.source, flow.groupAddress, flow.port]);
3395
+ return JSON.stringify([
3396
+ canonicalIpAddress(flow.source) ?? flow.source,
3397
+ canonicalIpAddress(flow.groupAddress) ?? flow.groupAddress,
3398
+ flow.port,
3399
+ ]);
3400
+ }
3401
+
3402
+ /** Datagram sources compared as IP addresses; spelling a provider does not parse falls back to equality. */
3403
+ function sameDatagramSource(a: string, b: string): boolean {
3404
+ return a === b || sameIpAddress(a, b);
3401
3405
  }
@@ -16,7 +16,9 @@ import {
16
16
  MMTP_PACKET_ID_MAX,
17
17
  MMTP_SIGNALING_PACKET_ID,
18
18
  readMMTPPacketId,
19
+ sameIpAddress,
19
20
  } from '@blockcast/mmt-base'
21
+ import { isBcpV2RecordCandidate } from '@blockcast/mmt-manifest-verify'
20
22
 
21
23
  export interface MulticastTransportConfig {
22
24
  /** SSM source; omitted on ASM. */
@@ -34,6 +36,20 @@ export interface MulticastTransportConfig {
34
36
  tracks?: { name: string; packetId: number }[]
35
37
  /** ATSC 3.0 tuning (`catalogAtsc3NetworkSource`); when set the join is the tuner. */
36
38
  atsc3?: { frequency: number; plpId?: number; serviceId?: number }
39
+ /**
40
+ * The join is a protected flow (multicast-02 7.2.3): its bc-provenance
41
+ * records ride in band on this `port`, so a datagram whose first four octets
42
+ * are "MHM2" passes ahead of the packet_id read and the allow-list, and is
43
+ * never counted toward a packet_id. On an unprotected join it is an
44
+ * unparseable MMTP packet, as before.
45
+ *
46
+ * A protected join admits only datagrams whose reported sender is the
47
+ * flow's S (`sourceAddress`, or `senderAddress` on ASM), compared as IP
48
+ * addresses, before anything hashes them (multicast-02 7.2.7). A datagram
49
+ * with no reported sender is dropped, and a join that cannot report one
50
+ * (no S, or an ATSC 3.0 tuner) is declined `invalid-request`.
51
+ */
52
+ protected?: boolean
37
53
  }
38
54
 
39
55
  export type ResolvedMulticastTransport = 'native' | 'amt'
@@ -139,7 +155,8 @@ export interface MulticastTransportStats {
139
155
  * one (G, port, packet_id); the first sender whose packet is accepted owns
140
156
  * the sub-flow until the next join, and every other sender is dropped here,
141
157
  * before any gap, duplicate or FEC ledger sees it. A join or a silence
142
- * failback releases the lock.
158
+ * failback releases the lock. On a protected join it also counts every
159
+ * datagram whose sender is not the flow's S, or is not reported.
143
160
  */
144
161
  packetsDroppedForeignSender: number
145
162
  }
@@ -206,7 +223,14 @@ function hasMulticastGateway(): boolean {
206
223
  return typeof getMulticastGlobals().multicast?.subscribe === 'function'
207
224
  }
208
225
 
209
- /** The `bytes` event's sender (multicast-contract 1.3.0); undefined when unknown. */
226
+ /**
227
+ * The `bytes` event's sender (multicast-contract 1.3.0); undefined when unknown.
228
+ *
229
+ * The provider filters (S, G, port) and owns the multicast-02 7.2.7
230
+ * destination MUST: the event carries no destination address, so libmmt
231
+ * cannot discard a datagram whose IP destination is not G (pim BLO-51627).
232
+ * On a protected flow libmmt checks the sender itself, against S.
233
+ */
210
234
  function senderOf(event: CustomEvent<unknown>): string | undefined {
211
235
  const sourceAddress = (event as { sourceAddress?: unknown }).sourceAddress
212
236
  return typeof sourceAddress === 'string' ? sourceAddress : undefined
@@ -333,6 +357,11 @@ export class MulticastTransport<TPacketContext = never> {
333
357
  // per packet once a count outgrows the small-integer range.
334
358
  readonly #acceptedBytesByPacketId = new Map<number, { bytes: number }>()
335
359
  #packetIds: ReadonlySet<number> | null = null
360
+ #protected = false
361
+ // A protected join's S, and the last sender spelling that matched it, so a
362
+ // matching sender parses once per spelling rather than per datagram.
363
+ #protectedSource: string | undefined = undefined
364
+ #matchedSource: string | undefined = undefined
336
365
  // The sender S this join's accepted packets came from, once known; see
337
366
  // packetsDroppedForeignSender. Cleared by every join and by a silence
338
367
  // failback, so a replacement publisher is not locked out.
@@ -396,6 +425,11 @@ export class MulticastTransport<TPacketContext = never> {
396
425
  // failure before it runs, so the catch classifies a decline by where the
397
426
  // join stopped, never by what the error says.
398
427
  let failure: MulticastJoinDeclineReason = 'invalid-request'
428
+ const protectedSource = config.protected === true ? config.sourceAddress ?? config.senderAddress : undefined
429
+ if (config.protected === true && (protectedSource === undefined || config.atsc3)) {
430
+ console.warn('[MulticastTransport] protected join refused: the path reports no per-datagram sender to check against S')
431
+ return this.#declined(generation, 'invalid-request')
432
+ }
399
433
  try {
400
434
  const packetIds = config.packetIds?.length ? new Set(config.packetIds) : null
401
435
  console.log(
@@ -444,8 +478,9 @@ export class MulticastTransport<TPacketContext = never> {
444
478
  // costs batching on every any-source join, including one on a pre-1.3.0
445
479
  // provider that sends no sender and so gets no lock for it; keep it
446
480
  // unless the provider can say it carries the sender.
481
+ // A protected join checks every datagram's sender, so it listens to `bytes` too.
447
482
  const anySource = !config.sourceAddress && !config.senderAddress && !config.atsc3
448
- const packetListener: PacketListener = !anySource && subscriptionDeliversPacketBatches(subscription)
483
+ const packetListener: PacketListener = !anySource && protectedSource === undefined && subscriptionDeliversPacketBatches(subscription)
449
484
  ? {
450
485
  type: 'packets',
451
486
  listener: (event) => {
@@ -462,6 +497,9 @@ export class MulticastTransport<TPacketContext = never> {
462
497
  }
463
498
  this.#packetListener = packetListener
464
499
  this.#packetIds = packetIds
500
+ this.#protected = config.protected === true
501
+ this.#protectedSource = protectedSource
502
+ this.#matchedSource = undefined
465
503
  this.#providerFilter = {
466
504
  acknowledged: packetIds,
467
505
  sent: 0,
@@ -578,7 +616,8 @@ export class MulticastTransport<TPacketContext = never> {
578
616
 
579
617
  /**
580
618
  * One packet from the subscription: filter, count, and hand it to the
581
- * callback. Returns whether it was delivered; the caller notes liveness.
619
+ * callback. Returns whether it was delivered as liveness (a delivered record
620
+ * candidate is not); the caller notes liveness.
582
621
  */
583
622
  #deliver(detail: unknown, sourceAddress?: string): boolean {
584
623
  const packet = normalizeBytesEventDetail(detail)
@@ -587,11 +626,16 @@ export class MulticastTransport<TPacketContext = never> {
587
626
  return false
588
627
  }
589
628
  if (!this.#acceptPacket(packet, sourceAddress)) return false
629
+ const liveness = !this.#isRecordCandidate(packet)
590
630
  // Read per packet: a callback that changes the admission (an in-place
591
631
  // rendition switch committing on this very packet) takes effect from the
592
632
  // next packet, including the rest of a batch.
593
633
  this.#onPacket(packet, this.#context)
594
- return true
634
+ return liveness
635
+ }
636
+
637
+ #isRecordCandidate(packet: Uint8Array): boolean {
638
+ return this.#protected && isBcpV2RecordCandidate(packet)
595
639
  }
596
640
 
597
641
  /**
@@ -647,8 +691,9 @@ export class MulticastTransport<TPacketContext = never> {
647
691
  if (this.#disposed) return
648
692
  const packet = data instanceof Uint8Array ? data : new Uint8Array(data)
649
693
  if (!this.#acceptPacket(packet, sourceAddress)) return
694
+ const liveness = !this.#isRecordCandidate(packet)
650
695
  this.#onPacket(packet, context)
651
- this.#resetWatchdog()
696
+ if (liveness) this.#resetWatchdog()
652
697
  }
653
698
 
654
699
  confirmVideo(): void {
@@ -990,6 +1035,9 @@ export class MulticastTransport<TPacketContext = never> {
990
1035
  this.#resolvedTransport = undefined
991
1036
  this.#delivery = undefined
992
1037
  this.#packetIds = null
1038
+ this.#protected = false
1039
+ this.#protectedSource = undefined
1040
+ this.#matchedSource = undefined
993
1041
  this.#providerFilter = null
994
1042
  this.#context = undefined
995
1043
  this.#withdrawnPacketIds.clear()
@@ -997,12 +1045,31 @@ export class MulticastTransport<TPacketContext = never> {
997
1045
 
998
1046
  /** The ingress filter; an accepted packet is counted here, once. */
999
1047
  #acceptPacket(packet: Uint8Array, sourceAddress?: string): boolean {
1048
+ const source = this.#protectedSource
1049
+ if (source !== undefined && (sourceAddress === undefined || sourceAddress !== this.#matchedSource)) {
1050
+ if (sourceAddress === undefined || !sameIpAddress(sourceAddress, source)) {
1051
+ this.#stats.packetsDroppedForeignSender++
1052
+ return false
1053
+ }
1054
+ this.#matchedSource = sourceAddress
1055
+ }
1000
1056
  // Absent means unknown (a pre-1.3.0 provider, a tuner): never a new sender.
1001
- const owner = this.#lockedTo?.() ?? this.#sender
1057
+ // A protected flow's S check above replaces the first-seen lock, which
1058
+ // compares spellings and would refuse S written another way.
1059
+ const owner = source === undefined ? this.#lockedTo?.() ?? this.#sender : undefined
1002
1060
  if (sourceAddress !== undefined && owner !== undefined && sourceAddress !== owner) {
1003
1061
  this.#stats.packetsDroppedForeignSender++
1004
1062
  return false
1005
1063
  }
1064
+ // A record candidate of a protected flow carries no packet_id: the router
1065
+ // splits it off before anything parses it (7.2.7). Step 1 has not run on
1066
+ // it yet, so it neither takes the sender lock nor counts as liveness
1067
+ // (#isRecordCandidate, #deliver): records alone must not hold off
1068
+ // silence failback.
1069
+ if (this.#isRecordCandidate(packet)) {
1070
+ this.#countAccepted(packet, null, undefined)
1071
+ return true
1072
+ }
1006
1073
  // This early read is intentional: rejected PIDs must not affect page
1007
1074
  // delivery, receive statistics, or the failback liveness watchdog. It
1008
1075
  // refuses exactly what parseMMTPHeader refuses but builds no header