@memberjunction/server 5.42.0 → 5.44.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 (101) hide show
  1. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +53 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts.map +1 -1
  9. package/dist/agentSessions/index.js +3 -1
  10. package/dist/agentSessions/index.js.map +1 -1
  11. package/dist/generated/generated.d.ts +1180 -51
  12. package/dist/generated/generated.d.ts.map +1 -1
  13. package/dist/generated/generated.js +35564 -29262
  14. package/dist/generated/generated.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +14 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  20. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  21. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  22. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  23. package/dist/resolvers/FileResolver.d.ts +74 -0
  24. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  25. package/dist/resolvers/FileResolver.js +211 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  30. package/dist/resolvers/QueryResolver.d.ts +6 -4
  31. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QueryResolver.js +29 -14
  33. package/dist/resolvers/QueryResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
  35. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
  37. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  38. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  39. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  40. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  41. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  42. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  43. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  44. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  45. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  46. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  48. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  49. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  50. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunTemplateResolver.js +8 -0
  52. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  53. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  54. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  55. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  56. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  57. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  58. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  59. package/dist/resolvers/peaksSidecar.js +51 -0
  60. package/dist/resolvers/peaksSidecar.js.map +1 -0
  61. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  62. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  63. package/dist/rest/MediaAccessKeys.js +96 -0
  64. package/dist/rest/MediaAccessKeys.js.map +1 -0
  65. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  66. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  67. package/dist/rest/MediaStreamHandler.js +192 -0
  68. package/dist/rest/MediaStreamHandler.js.map +1 -0
  69. package/dist/rest/mediaRange.d.ts +41 -0
  70. package/dist/rest/mediaRange.d.ts.map +1 -0
  71. package/dist/rest/mediaRange.js +60 -0
  72. package/dist/rest/mediaRange.js.map +1 -0
  73. package/package.json +83 -82
  74. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  75. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  76. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  77. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  78. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  79. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  80. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  81. package/src/__tests__/SessionManager.test.ts +2 -0
  82. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  83. package/src/agentSessions/SessionJanitor.ts +15 -1
  84. package/src/agentSessions/SessionManager.ts +62 -10
  85. package/src/agentSessions/index.ts +3 -1
  86. package/src/generated/generated.ts +17659 -13322
  87. package/src/index.ts +15 -1
  88. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  89. package/src/resolvers/FileResolver.ts +199 -1
  90. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  91. package/src/resolvers/QueryResolver.ts +33 -19
  92. package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
  93. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  94. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  95. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  96. package/src/resolvers/RunTemplateResolver.ts +8 -0
  97. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  98. package/src/resolvers/peaksSidecar.ts +52 -0
  99. package/src/rest/MediaAccessKeys.ts +121 -0
  100. package/src/rest/MediaStreamHandler.ts +220 -0
  101. package/src/rest/mediaRange.ts +76 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @fileoverview Short-lived HS256 token manager for authenticated media streaming.
3
+ *
4
+ * Media-access tokens are minted by the `CreateMediaAccessToken` GraphQL mutation
5
+ * (after a per-user permission check on the `MJ: Files` row) and verified by the
6
+ * `/media/:fileId` streaming route. The token IS the capability: access is checked
7
+ * once at mint time, and the route trusts a valid, unexpired, file-matching token.
8
+ *
9
+ * This is intentionally a SEPARATE, self-contained mechanism from the magic-link
10
+ * RS256 manager: media streaming must work on EVERY deployment, whereas the
11
+ * magic-link keypair is only initialized when `magicLink.enabled` is configured.
12
+ * We sign HS256 with the deployment's symmetric secret (`MJ_BASE_ENCRYPTION_KEY`),
13
+ * falling back to an ephemeral per-process secret (with a warning) when unset —
14
+ * the same degradation MagicLinkKeyManager uses for its ephemeral keypair.
15
+ *
16
+ * @module @memberjunction/server/rest/MediaAccessKeys
17
+ */
18
+
19
+ import { BaseSingleton } from '@memberjunction/global';
20
+ import { LogStatus } from '@memberjunction/core';
21
+ import { randomBytes } from 'node:crypto';
22
+ import jwt from 'jsonwebtoken';
23
+
24
+ /** The token's `typ` claim — the route rejects any token whose `typ` differs. */
25
+ export const MEDIA_ACCESS_TOKEN_TYPE = 'media-access';
26
+
27
+ /** Default media-access token lifetime (hours). Long enough to cover a full playback session. */
28
+ export const MEDIA_ACCESS_DEFAULT_TTL_HOURS = 4;
29
+
30
+ /** The claims carried by a media-access token. */
31
+ export interface MediaAccessTokenClaims {
32
+ /** The `MJ: Files` id this token grants streaming access to. */
33
+ fileId: string;
34
+ /** The id of the user the token was minted for (audit/forensics only — access was already checked). */
35
+ userId: string;
36
+ /** Discriminator — always {@link MEDIA_ACCESS_TOKEN_TYPE}. */
37
+ typ: typeof MEDIA_ACCESS_TOKEN_TYPE;
38
+ }
39
+
40
+ /** Outcome of verifying a media-access token. */
41
+ export type MediaTokenVerifyResult =
42
+ | { Valid: true; Claims: MediaAccessTokenClaims }
43
+ | { Valid: false };
44
+
45
+ /**
46
+ * Signs and verifies short-lived HS256 media-access tokens. Singleton so the
47
+ * signing secret is stable for the process lifetime. The secret is resolved
48
+ * lazily on first use from `MJ_BASE_ENCRYPTION_KEY`; if unset, an ephemeral
49
+ * 32-byte secret is generated (outstanding tokens die on restart — acceptable
50
+ * for a 4h-TTL streaming credential).
51
+ */
52
+ export class MediaAccessKeyManager extends BaseSingleton<MediaAccessKeyManager> {
53
+ private secret = '';
54
+ private _initialized = false;
55
+
56
+ // Public to satisfy BaseSingleton.getInstance's `this: new () => T` constraint.
57
+ public constructor() {
58
+ super();
59
+ }
60
+
61
+ public static get Instance(): MediaAccessKeyManager {
62
+ return MediaAccessKeyManager.getInstance<MediaAccessKeyManager>();
63
+ }
64
+
65
+ /** Resolves the signing secret once, preferring the deployment's symmetric key. */
66
+ private ensureSecret(): void {
67
+ if (this._initialized) {
68
+ return;
69
+ }
70
+ const configured = process.env.MJ_BASE_ENCRYPTION_KEY?.trim();
71
+ if (configured) {
72
+ this.secret = configured;
73
+ } else {
74
+ this.secret = randomBytes(32).toString('base64');
75
+ LogStatus(
76
+ '[MediaAccess] No MJ_BASE_ENCRYPTION_KEY configured — generated an EPHEMERAL HS256 secret for ' +
77
+ 'media-access tokens. Outstanding media URLs will be invalidated on restart. Set ' +
78
+ 'MJ_BASE_ENCRYPTION_KEY for stable signing.',
79
+ );
80
+ }
81
+ this._initialized = true;
82
+ }
83
+
84
+ /**
85
+ * Mints a signed media-access token for a file + user, expiring after `ttlHours`.
86
+ *
87
+ * @returns `{ Token, ExpiresAt }` — the compact JWT and its absolute expiry.
88
+ */
89
+ public Sign(fileId: string, userId: string, ttlHours: number = MEDIA_ACCESS_DEFAULT_TTL_HOURS): { Token: string; ExpiresAt: Date } {
90
+ this.ensureSecret();
91
+ const claims: MediaAccessTokenClaims = { fileId, userId, typ: MEDIA_ACCESS_TOKEN_TYPE };
92
+ const expiresInSeconds = Math.floor(ttlHours * 3600);
93
+ const token = jwt.sign(claims, this.secret, { algorithm: 'HS256', expiresIn: expiresInSeconds });
94
+ const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
95
+ return { Token: token, ExpiresAt: expiresAt };
96
+ }
97
+
98
+ /**
99
+ * Verifies a token's signature, expiry, and `typ`. Returns the typed claims on
100
+ * success. Never throws — an invalid/expired/malformed token returns `{ Valid: false }`.
101
+ */
102
+ public Verify(token: string): MediaTokenVerifyResult {
103
+ this.ensureSecret();
104
+ try {
105
+ const decoded = jwt.verify(token, this.secret, { algorithms: ['HS256'] });
106
+ if (
107
+ typeof decoded === 'object' && decoded !== null &&
108
+ (decoded as { typ?: unknown }).typ === MEDIA_ACCESS_TOKEN_TYPE &&
109
+ typeof (decoded as { fileId?: unknown }).fileId === 'string' &&
110
+ typeof (decoded as { userId?: unknown }).userId === 'string'
111
+ ) {
112
+ const d = decoded as { fileId: string; userId: string };
113
+ return { Valid: true, Claims: { fileId: d.fileId, userId: d.userId, typ: MEDIA_ACCESS_TOKEN_TYPE } };
114
+ }
115
+ return { Valid: false };
116
+ } catch {
117
+ // Signature mismatch, expiry, or malformed token — all map to "not valid".
118
+ return { Valid: false };
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @fileoverview Authenticated HTTP-Range media streaming route (`GET /media/:fileId`).
3
+ *
4
+ * Streams an `MJ: Files` object's bytes (optionally a byte range) to the browser so
5
+ * `<audio>`/`<video>` elements get native progressive playback + seek-before-download
6
+ * for large media — instead of the whole file being base64'd over GraphQL.
7
+ *
8
+ * Auth model: the request carries a short-lived signed token (`?token=`) minted by
9
+ * `CreateMediaAccessToken` AFTER a per-user permission check. The token IS the
10
+ * capability: this route re-verifies its signature/expiry/`typ` and that it matches
11
+ * the `:fileId`, then loads the file under a SYSTEM context (it only needs
12
+ * `ProviderKey`/`ProviderID` to locate the bytes — no row-level re-check, since
13
+ * access was already authorized at mint).
14
+ *
15
+ * Mount unauthenticated, BEFORE the unified auth middleware:
16
+ * `app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter())`
17
+ *
18
+ * @module @memberjunction/server/rest/MediaStreamHandler
19
+ */
20
+
21
+ import express, { type Router, type Request, type Response } from 'express';
22
+ import { LogError, Metadata, UserInfo } from '@memberjunction/core';
23
+ import { MJFileEntity } from '@memberjunction/core-entities';
24
+ import { FileStorageEngine } from '@memberjunction/storage';
25
+ import type { FileStorageBase, ByteRange } from '@memberjunction/storage';
26
+ import { getSystemUser } from '../auth/index.js';
27
+ import { MediaAccessKeyManager } from './MediaAccessKeys.js';
28
+ import { parseRange, parseRangeHeaderLoose } from './mediaRange.js';
29
+
30
+ /** A located bytes source for a file: the driver + the provider key to read. */
31
+ interface FileBytesSource {
32
+ driver: FileStorageBase;
33
+ providerKey: string;
34
+ contentType: string;
35
+ }
36
+
37
+
38
+ /**
39
+ * Builds the Express router exposing `GET /media/:fileId`. Stateless — verification
40
+ * and byte-source resolution happen per request.
41
+ */
42
+ export function createMediaStreamRouter(): Router {
43
+ const router = express.Router();
44
+ router.get('/:fileId', async (req: Request, res: Response) => {
45
+ await handleMediaRequest(req, res);
46
+ });
47
+ return router;
48
+ }
49
+
50
+ /** Top-level request handler: verify token → resolve bytes → stream/buffer with Range support. */
51
+ async function handleMediaRequest(req: Request, res: Response): Promise<void> {
52
+ const fileId = req.params.fileId;
53
+ const token = typeof req.query.token === 'string' ? req.query.token : '';
54
+
55
+ const claims = verifyMediaToken(token, fileId);
56
+ if (!claims) {
57
+ // No body leak — a bad/expired/mismatched token is indistinguishable from "forbidden".
58
+ res.status(403).end();
59
+ return;
60
+ }
61
+
62
+ // Never let CDNs or shared caches retain authorized media bytes.
63
+ res.setHeader('Cache-Control', 'private, no-store');
64
+
65
+ try {
66
+ const source = await resolveFileBytesSource(fileId);
67
+ if (!source) {
68
+ res.status(404).end();
69
+ return;
70
+ }
71
+ await streamOrBuffer(req, res, source);
72
+ } catch (error) {
73
+ LogError(`[MediaStream] Failed to serve file ${fileId}: ${error instanceof Error ? error.message : String(error)}`);
74
+ if (!res.headersSent) {
75
+ res.status(500).end();
76
+ } else {
77
+ res.destroy();
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Verifies the token and that it grants access to THIS file. Returns null on any failure
84
+ * (signature, expiry, `typ`, or fileId mismatch) so the caller can 403 uniformly.
85
+ */
86
+ function verifyMediaToken(token: string, fileId: string): { fileId: string; userId: string } | null {
87
+ if (!token) {
88
+ return null;
89
+ }
90
+ const result = MediaAccessKeyManager.Instance.Verify(token);
91
+ if (!result.Valid) {
92
+ return null;
93
+ }
94
+ // The token's fileId must match the path — a token minted for file A cannot stream file B.
95
+ if (result.Claims.fileId !== fileId) {
96
+ return null;
97
+ }
98
+ return { fileId: result.Claims.fileId, userId: result.Claims.userId };
99
+ }
100
+
101
+ /**
102
+ * Loads the file under a SYSTEM context and resolves its storage driver + provider key.
103
+ * Returns null when the file row, its account, or its provider key can't be resolved
104
+ * (→ 404). Mirrors `readRealtimeRecordingFile`'s account-resolution (engine config with
105
+ * force-refresh fallback for newly-provisioned accounts).
106
+ */
107
+ async function resolveFileBytesSource(fileId: string): Promise<FileBytesSource | null> {
108
+ const systemUser: UserInfo = await getSystemUser();
109
+
110
+ // The /media route runs pre-auth on the server's own provider; access was already authorized
111
+ // at token-mint time, so this load only locates the bytes.
112
+ const md = new Metadata(); // global-provider-ok: pre-auth system-context route, no per-request provider
113
+ const file = await md.GetEntityObject<MJFileEntity>('MJ: Files', systemUser);
114
+ if (!await file.Load(fileId) || !file.ProviderKey) {
115
+ return null;
116
+ }
117
+
118
+ // Resolve the storage account for the file's provider; force-refresh once if a
119
+ // newly-provisioned account isn't in the cache yet.
120
+ await FileStorageEngine.Instance.Config(false, systemUser);
121
+ let accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
122
+ if (accounts.length === 0) {
123
+ await FileStorageEngine.Instance.Config(true, systemUser);
124
+ accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
125
+ }
126
+ const account = accounts[0];
127
+ if (!account) {
128
+ return null;
129
+ }
130
+
131
+ const driver = await FileStorageEngine.Instance.GetDriver(account.ID, systemUser);
132
+ return {
133
+ driver,
134
+ providerKey: file.ProviderKey,
135
+ contentType: file.ContentType ?? 'application/octet-stream',
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Streams the object honoring an HTTP `Range` request. Prefers true streaming
141
+ * (`GetObjectStream`) for streaming-capable drivers; gracefully falls back to a
142
+ * full buffered `GetObject` (slicing for Range) for drivers that don't.
143
+ */
144
+ async function streamOrBuffer(req: Request, res: Response, source: FileBytesSource): Promise<void> {
145
+ const rangeHeader = typeof req.headers.range === 'string' ? req.headers.range : undefined;
146
+
147
+ if (source.driver.SupportsStreaming) {
148
+ await serveViaStream(res, source, rangeHeader);
149
+ } else {
150
+ await serveViaBuffer(res, source, rangeHeader);
151
+ }
152
+ }
153
+
154
+ /** True-streaming path: `GetObjectStream` + pipe, with 206 when a Range was honored. */
155
+ async function serveViaStream(res: Response, source: FileBytesSource, rangeHeader: string | undefined): Promise<void> {
156
+ const range = rangeHeader ? parseRangeHeaderLoose(rangeHeader) : undefined;
157
+ // Omit End for an open-ended range so the driver streams to EOF (per ByteRange semantics).
158
+ const streamRange: ByteRange | undefined = range
159
+ ? (range.end == null ? { Start: range.start } : { Start: range.start, End: range.end })
160
+ : undefined;
161
+
162
+ const result = await source.driver.GetObjectStream({ fullPath: source.providerKey, Range: streamRange });
163
+
164
+ res.setHeader('Accept-Ranges', 'bytes');
165
+ res.setHeader('Content-Type', result.ContentType ?? source.contentType);
166
+ if (result.ContentLength != null) {
167
+ res.setHeader('Content-Length', String(result.ContentLength));
168
+ }
169
+ if (result.ContentRange) {
170
+ const { Start, End, Total } = result.ContentRange;
171
+ res.setHeader('Content-Range', `bytes ${Start}-${End}/${Total}`);
172
+ res.status(206);
173
+ } else {
174
+ res.status(200);
175
+ }
176
+
177
+ // Tear down the source stream if the client aborts (closed tab, seek, etc.).
178
+ res.on('close', () => result.Stream.destroy());
179
+ result.Stream.on('error', (err) => {
180
+ LogError(`[MediaStream] Source stream error: ${err instanceof Error ? err.message : String(err)}`);
181
+ if (!res.headersSent) {
182
+ res.status(500).end();
183
+ } else {
184
+ res.destroy();
185
+ }
186
+ });
187
+
188
+ result.Stream.pipe(res);
189
+ }
190
+
191
+ /** Fallback path: full buffered `GetObject`, slicing the buffer for Range (206) or sending whole (200). */
192
+ async function serveViaBuffer(res: Response, source: FileBytesSource, rangeHeader: string | undefined): Promise<void> {
193
+ const buffer = await source.driver.GetObject({ fullPath: source.providerKey });
194
+ const total = buffer.length;
195
+
196
+ res.setHeader('Accept-Ranges', 'bytes');
197
+ res.setHeader('Content-Type', source.contentType);
198
+
199
+ if (!rangeHeader) {
200
+ res.status(200);
201
+ res.setHeader('Content-Length', String(total));
202
+ res.end(buffer);
203
+ return;
204
+ }
205
+
206
+ const range = parseRange(rangeHeader, total);
207
+ if (!range) {
208
+ // Unsatisfiable range — per RFC 7233, 416 + Content-Range with the total size.
209
+ res.setHeader('Content-Range', `bytes */${total}`);
210
+ res.status(416).end();
211
+ return;
212
+ }
213
+
214
+ const slice = buffer.subarray(range.start, range.end + 1);
215
+ res.status(206);
216
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${total}`);
217
+ res.setHeader('Content-Length', String(slice.length));
218
+ res.end(slice);
219
+ }
220
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @fileoverview Pure HTTP `Range` header parsing for the `/media/:fileId` stream route.
3
+ *
4
+ * Split out of {@link module:@memberjunction/server/rest/MediaStreamHandler} (which pulls in
5
+ * auth/config/storage at module load) so this load-bearing parsing logic is unit-testable in
6
+ * isolation — same pattern as `peaksSidecar.ts`. These functions decide whether a request gets
7
+ * a 206 partial body (and over which byte window) vs. a full 200 / a 416.
8
+ *
9
+ * Both helpers are deliberately strict single-range parsers: anything malformed, multi-range,
10
+ * or suffix-only (`bytes=-N`) yields `undefined`, letting the caller fall back to a full read
11
+ * (streaming path) or a 416 (buffer path).
12
+ *
13
+ * @module @memberjunction/server/rest/mediaRange
14
+ */
15
+
16
+ /** A parsed, validated `Range` request against a known total size. */
17
+ export interface ParsedRange {
18
+ start: number;
19
+ end: number;
20
+ }
21
+
22
+ /**
23
+ * A parsed `Range` request WITHOUT a known total (streaming path). `end` is undefined
24
+ * for an open-ended `bytes=start-` request, matching `ByteRange`'s "omit End = EOF".
25
+ */
26
+ export interface ParsedOpenRange {
27
+ start: number;
28
+ end?: number;
29
+ }
30
+
31
+ /**
32
+ * Parses a single-range `bytes=start-end` header WITHOUT a known total (streaming path).
33
+ * Returns the inclusive offsets, or undefined when the header is malformed/multi-range/
34
+ * suffix-only (those we leave to the driver as a full read). `end` is left undefined when
35
+ * open-ended (`bytes=start-`) so the driver streams to EOF.
36
+ */
37
+ export function parseRangeHeaderLoose(rangeHeader: string): ParsedOpenRange | undefined {
38
+ const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
39
+ if (!match) {
40
+ return undefined;
41
+ }
42
+ const start = Number(match[1]);
43
+ if (!Number.isFinite(start)) {
44
+ return undefined;
45
+ }
46
+ if (match[2] === '') {
47
+ return { start };
48
+ }
49
+ const end = Number(match[2]);
50
+ if (!Number.isFinite(end) || end < start) {
51
+ return undefined;
52
+ }
53
+ return { start, end };
54
+ }
55
+
56
+ /**
57
+ * Parses a single-range `bytes=start-end` header against a known total (buffer path),
58
+ * clamping `end` to the last byte. Returns undefined when unsatisfiable (→ 416) or
59
+ * malformed/multi-range.
60
+ */
61
+ export function parseRange(rangeHeader: string, total: number): ParsedRange | undefined {
62
+ const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
63
+ if (!match) {
64
+ return undefined;
65
+ }
66
+ const start = Number(match[1]);
67
+ if (!Number.isFinite(start) || start >= total) {
68
+ return undefined; // start past EOF → unsatisfiable
69
+ }
70
+ const requestedEnd = match[2] === '' ? total - 1 : Number(match[2]);
71
+ const end = Math.min(requestedEnd, total - 1);
72
+ if (end < start) {
73
+ return undefined;
74
+ }
75
+ return { start, end };
76
+ }