@blockcast/mmt-container 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +9 -7
  2. package/dist/browser-runtime.js +6931 -6685
  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.map +1 -1
  11. package/dist/delta-fallback-config.js.map +1 -1
  12. package/dist/fec-manager.d.ts +25 -1
  13. package/dist/fec-manager.d.ts.map +1 -1
  14. package/dist/fec-manager.js +208 -15
  15. package/dist/fec-manager.js.map +2 -2
  16. package/dist/fec-state.js.map +1 -1
  17. package/dist/fec-worker-setup.js.map +1 -1
  18. package/dist/held-layer-admission.d.ts +8 -0
  19. package/dist/held-layer-admission.d.ts.map +1 -1
  20. package/dist/held-layer-admission.js +22 -1
  21. package/dist/held-layer-admission.js.map +2 -2
  22. package/dist/iso-block-locator.js.map +1 -1
  23. package/dist/mfu-reassembler.js.map +1 -1
  24. package/dist/mmtp-container-parser.js.map +1 -1
  25. package/dist/mmtp-container-parser.no-modules.js +127 -322
  26. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  27. package/dist/mmtp-decode-pipeline.js.map +1 -1
  28. package/dist/mmtp-fec-session.d.ts.map +1 -1
  29. package/dist/mmtp-fec-session.js +61 -15
  30. package/dist/mmtp-fec-session.js.map +3 -3
  31. package/dist/mmtp-router.d.ts +11 -6
  32. package/dist/mmtp-router.d.ts.map +1 -1
  33. package/dist/mmtp-router.js +47 -10
  34. package/dist/mmtp-router.js.map +3 -3
  35. package/dist/multicast-auth-catalog.d.ts +8 -8
  36. package/dist/multicast-auth-catalog.d.ts.map +1 -1
  37. package/dist/multicast-auth-catalog.js +171 -75
  38. package/dist/multicast-auth-catalog.js.map +4 -4
  39. package/dist/multicast-input.d.ts +5 -5
  40. package/dist/multicast-input.d.ts.map +1 -1
  41. package/dist/multicast-input.js +126 -49
  42. package/dist/multicast-input.js.map +3 -3
  43. package/dist/multicast-manager.d.ts +11 -4
  44. package/dist/multicast-manager.d.ts.map +1 -1
  45. package/dist/multicast-manager.js +7922 -7700
  46. package/dist/multicast-manager.js.map +4 -4
  47. package/dist/multicast-transport.d.ts +8 -0
  48. package/dist/multicast-transport.d.ts.map +1 -1
  49. package/dist/multicast-transport.js +27 -3
  50. package/dist/multicast-transport.js.map +3 -3
  51. package/dist/multicast-unwrap.js +2 -2
  52. package/dist/multicast-unwrap.js.map +2 -2
  53. package/dist/no-modules.js +81 -278
  54. package/dist/no-modules.js.map +4 -4
  55. package/dist/repair-fetch.js.map +1 -1
  56. package/dist/signaling.js.map +1 -1
  57. package/dist/timestamp-codec.js.map +1 -1
  58. package/dist/wasm-mfu-reassembler.js.map +1 -1
  59. package/dist/wasm-mmtp-container-parser.js.map +1 -1
  60. package/package.json +5 -5
  61. package/schemas/catalog.schema.json +15 -7
  62. package/src/carp-catalog.ts +14 -13
  63. package/src/fec-manager.ts +89 -14
  64. package/src/held-layer-admission.ts +38 -1
  65. package/src/mmtp-fec-session.ts +20 -12
  66. package/src/mmtp-router.ts +65 -16
  67. package/src/multicast-auth-catalog.ts +43 -36
  68. package/src/multicast-input.ts +28 -8
  69. package/src/multicast-manager.ts +38 -34
  70. package/src/multicast-transport.ts +31 -3
  71. package/test-vectors/bc-provenance-v2.json +614 -175
  72. package/test-vectors/catalog/README.md +24 -8
  73. package/test-vectors/catalog/negative/multicast-auth-asm-missing-sender-address.json +0 -2
  74. package/test-vectors/catalog/negative/{multicast-auth-manifest-port-equals-port.json → multicast-auth-draft01-manifest-port.json} +1 -1
  75. package/test-vectors/catalog/negative/multicast-auth-duplicate-first-epoch.json +0 -1
  76. package/test-vectors/catalog/negative/multicast-auth-duplicate-kid.json +0 -1
  77. package/test-vectors/catalog/negative/multicast-auth-endpoint-auth-without-object-auth.json +0 -1
  78. package/test-vectors/catalog/negative/multicast-auth-endpoint-missing-bandwidth.json +0 -1
  79. package/test-vectors/catalog/negative/multicast-auth-manifest-timer-out-of-range.json +0 -1
  80. package/test-vectors/catalog/negative/multicast-auth-sender-address-on-ssm.json +0 -1
  81. package/test-vectors/catalog/negative/multicast-auth-shared-sg-port.json +73 -0
  82. package/test-vectors/catalog/negative/multicast-auth-version-1.json +0 -1
  83. package/test-vectors/catalog/negative/multicast-auth-version-3.json +0 -1
  84. package/test-vectors/catalog/negative/multicast-auth-version-missing.json +0 -1
  85. package/test-vectors/catalog/negative/multicast-endpoint-address-family-mismatch.json +55 -0
  86. package/test-vectors/catalog/negative/multicast-endpoint-address-host-name.json +55 -0
  87. package/test-vectors/catalog/negative/multicast-endpoint-address-leading-zero.json +55 -0
  88. package/test-vectors/catalog/negative/multicast-endpoint-address-ls32-leading-zero.json +55 -0
  89. package/test-vectors/catalog/negative/multicast-endpoint-address-mapped-family-mismatch.json +55 -0
  90. package/test-vectors/catalog/negative/multicast-endpoint-address-zone-id.json +55 -0
  91. package/test-vectors/catalog/positive/multicast-auth-asm-sender.json +0 -2
  92. 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
  93. 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
  94. package/test-vectors/catalog/positive/multicast-auth-max-auth-delay-string.json +55 -0
  95. package/test-vectors/catalog/positive/multicast-auth-rotation.json +0 -1
  96. package/test-vectors/catalog/positive/multicast-draft01-7-2-6-example.json +0 -1
  97. package/test-vectors/catalog/section-codes.json +8 -3
  98. package/test-vectors/catalog-multicast-auth-v1.json +0 -1
  99. package/test-vectors/catalog-validation.json +3 -3
  100. 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
  }
@@ -17,6 +17,7 @@ import {
17
17
  MMTP_SIGNALING_PACKET_ID,
18
18
  readMMTPPacketId,
19
19
  } from '@blockcast/mmt-base'
20
+ import { isBcpV2RecordCandidate } from '@blockcast/mmt-manifest-verify'
20
21
 
21
22
  export interface MulticastTransportConfig {
22
23
  /** SSM source; omitted on ASM. */
@@ -34,6 +35,14 @@ export interface MulticastTransportConfig {
34
35
  tracks?: { name: string; packetId: number }[]
35
36
  /** ATSC 3.0 tuning (`catalogAtsc3NetworkSource`); when set the join is the tuner. */
36
37
  atsc3?: { frequency: number; plpId?: number; serviceId?: number }
38
+ /**
39
+ * The join is a protected flow (multicast-02 7.2.3): its bc-provenance
40
+ * records ride in band on this `port`, so a datagram whose first four octets
41
+ * are "MHM2" passes ahead of the packet_id read and the allow-list, and is
42
+ * never counted toward a packet_id. On an unprotected join it is an
43
+ * unparseable MMTP packet, as before.
44
+ */
45
+ protected?: boolean
37
46
  }
38
47
 
39
48
  export type ResolvedMulticastTransport = 'native' | 'amt'
@@ -333,6 +342,7 @@ export class MulticastTransport<TPacketContext = never> {
333
342
  // per packet once a count outgrows the small-integer range.
334
343
  readonly #acceptedBytesByPacketId = new Map<number, { bytes: number }>()
335
344
  #packetIds: ReadonlySet<number> | null = null
345
+ #protected = false
336
346
  // The sender S this join's accepted packets came from, once known; see
337
347
  // packetsDroppedForeignSender. Cleared by every join and by a silence
338
348
  // failback, so a replacement publisher is not locked out.
@@ -462,6 +472,7 @@ export class MulticastTransport<TPacketContext = never> {
462
472
  }
463
473
  this.#packetListener = packetListener
464
474
  this.#packetIds = packetIds
475
+ this.#protected = config.protected === true
465
476
  this.#providerFilter = {
466
477
  acknowledged: packetIds,
467
478
  sent: 0,
@@ -578,7 +589,8 @@ export class MulticastTransport<TPacketContext = never> {
578
589
 
579
590
  /**
580
591
  * One packet from the subscription: filter, count, and hand it to the
581
- * callback. Returns whether it was delivered; the caller notes liveness.
592
+ * callback. Returns whether it was delivered as liveness (a delivered record
593
+ * candidate is not); the caller notes liveness.
582
594
  */
583
595
  #deliver(detail: unknown, sourceAddress?: string): boolean {
584
596
  const packet = normalizeBytesEventDetail(detail)
@@ -587,11 +599,16 @@ export class MulticastTransport<TPacketContext = never> {
587
599
  return false
588
600
  }
589
601
  if (!this.#acceptPacket(packet, sourceAddress)) return false
602
+ const liveness = !this.#isRecordCandidate(packet)
590
603
  // Read per packet: a callback that changes the admission (an in-place
591
604
  // rendition switch committing on this very packet) takes effect from the
592
605
  // next packet, including the rest of a batch.
593
606
  this.#onPacket(packet, this.#context)
594
- return true
607
+ return liveness
608
+ }
609
+
610
+ #isRecordCandidate(packet: Uint8Array): boolean {
611
+ return this.#protected && isBcpV2RecordCandidate(packet)
595
612
  }
596
613
 
597
614
  /**
@@ -647,8 +664,9 @@ export class MulticastTransport<TPacketContext = never> {
647
664
  if (this.#disposed) return
648
665
  const packet = data instanceof Uint8Array ? data : new Uint8Array(data)
649
666
  if (!this.#acceptPacket(packet, sourceAddress)) return
667
+ const liveness = !this.#isRecordCandidate(packet)
650
668
  this.#onPacket(packet, context)
651
- this.#resetWatchdog()
669
+ if (liveness) this.#resetWatchdog()
652
670
  }
653
671
 
654
672
  confirmVideo(): void {
@@ -990,6 +1008,7 @@ export class MulticastTransport<TPacketContext = never> {
990
1008
  this.#resolvedTransport = undefined
991
1009
  this.#delivery = undefined
992
1010
  this.#packetIds = null
1011
+ this.#protected = false
993
1012
  this.#providerFilter = null
994
1013
  this.#context = undefined
995
1014
  this.#withdrawnPacketIds.clear()
@@ -1003,6 +1022,15 @@ export class MulticastTransport<TPacketContext = never> {
1003
1022
  this.#stats.packetsDroppedForeignSender++
1004
1023
  return false
1005
1024
  }
1025
+ // A record candidate of a protected flow carries no packet_id: the router
1026
+ // splits it off before anything parses it (7.2.7). Step 1 has not run on
1027
+ // it yet, so it neither takes the sender lock nor counts as liveness
1028
+ // (#isRecordCandidate, #deliver): records alone must not hold off
1029
+ // silence failback.
1030
+ if (this.#isRecordCandidate(packet)) {
1031
+ this.#countAccepted(packet, null, undefined)
1032
+ return true
1033
+ }
1006
1034
  // This early read is intentional: rejected PIDs must not affect page
1007
1035
  // delivery, receive statistics, or the failback liveness watchdog. It
1008
1036
  // refuses exactly what parseMMTPHeader refuses but builds no header