machine-bridge-mcp 3.0.0-beta.17 → 3.0.0-beta.21

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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +9 -7
  4. package/browser-extension/browser-error-boundary.js +45 -0
  5. package/browser-extension/browser-operations.js +1 -1
  6. package/browser-extension/manifest.json +1 -1
  7. package/browser-extension/service-worker.js +9 -8
  8. package/docs/ARCHITECTURE.md +17 -15
  9. package/docs/AUDIT.md +30 -0
  10. package/docs/ENGINEERING.md +3 -3
  11. package/docs/GETTING_STARTED.md +1 -1
  12. package/docs/LOCAL_AUTHORIZATION.md +2 -2
  13. package/docs/LOCAL_AUTOMATION.md +1 -1
  14. package/docs/LOGGING.md +3 -1
  15. package/docs/MULTI_ACCOUNT.md +2 -2
  16. package/docs/OPERATIONS.md +13 -5
  17. package/docs/OVERVIEW.md +2 -2
  18. package/docs/PRIVACY.md +1 -1
  19. package/docs/TESTING.md +17 -13
  20. package/docs/THREAT_MODEL.md +13 -8
  21. package/docs/UPGRADING.md +19 -1
  22. package/package.json +1 -1
  23. package/scripts/coverage-check.mjs +12 -0
  24. package/src/local/account-admin.mjs +68 -1
  25. package/src/local/agent-skill-discovery.mjs +16 -5
  26. package/src/local/app-automation.mjs +27 -7
  27. package/src/local/cli-options.mjs +1 -1
  28. package/src/local/path-inspection.mjs +23 -0
  29. package/src/local/process-tree-ownership.mjs +17 -5
  30. package/src/local/process-tree.mjs +6 -2
  31. package/src/local/relay-connection.mjs +24 -11
  32. package/src/local/resource-operations.mjs +37 -8
  33. package/src/local/runtime-activation.mjs +16 -0
  34. package/src/local/runtime-capabilities.mjs +15 -3
  35. package/src/local/runtime.mjs +7 -4
  36. package/src/local/worker-deployment.mjs +16 -6
  37. package/src/local/workspace-file-service.mjs +29 -11
  38. package/src/worker/daemon-socket-attachment.ts +52 -0
  39. package/src/worker/daemon-sockets.ts +17 -48
  40. package/src/worker/durable-stream-calls.ts +129 -0
  41. package/src/worker/durable-stream-result.ts +22 -0
  42. package/src/worker/http.ts +37 -15
  43. package/src/worker/index.ts +143 -146
  44. package/src/worker/mcp-pending-call-expiry.ts +20 -0
  45. package/src/worker/mcp-pending-call-inspection.ts +33 -0
  46. package/src/worker/mcp-pending-call-records.ts +58 -0
  47. package/src/worker/mcp-pending-call-storage.ts +18 -0
  48. package/src/worker/mcp-pending-call-store.ts +285 -0
  49. package/src/worker/mcp-resumption-index.ts +41 -0
  50. package/src/worker/mcp-resumption-records.ts +8 -2
  51. package/src/worker/mcp-resumption.ts +84 -86
  52. package/src/worker/mcp-stream-dispatch.ts +42 -54
  53. package/src/worker/mcp-stream-proxy.ts +7 -69
  54. package/src/worker/mcp-stream-subscription.ts +98 -0
  55. package/src/worker/oauth-controller.ts +11 -2
  56. package/src/worker/oauth-refresh-exchange.ts +147 -0
  57. package/src/worker/oauth-refresh-families.ts +59 -17
  58. package/src/worker/oauth-state.ts +13 -2
  59. package/src/worker/oauth-token-derivation.ts +33 -0
  60. package/src/worker/oauth-token-issuance.ts +106 -0
  61. package/src/worker/oauth-tokens.ts +12 -184
  62. package/src/worker/observability.ts +19 -0
  63. package/src/worker/pending-admission.ts +15 -0
  64. package/src/worker/pending-call-contract.ts +4 -7
  65. package/src/worker/pending-calls.ts +4 -10
  66. package/src/worker/runtime-alarm-storage.ts +35 -0
  67. package/src/worker/runtime-alarm.ts +30 -20
  68. package/src/worker/websocket-protocol.ts +4 -0
  69. package/src/worker/worker-edge-guard.ts +81 -0
  70. package/src/worker/worker-edge-log.ts +63 -0
  71. package/src/worker/worker-entry.ts +50 -0
  72. package/src/worker/worker-metadata.ts +39 -0
  73. package/src/worker/worker-static-routes.ts +38 -50
  74. package/wrangler.jsonc +8 -0
@@ -0,0 +1,52 @@
1
+ import { sanitizeDaemonChallengeAttachment } from "./daemon-auth.ts";
2
+ import type { DaemonRole } from "./daemon-liveness.ts";
3
+ import { sanitizeMetadataText } from "./http.ts";
4
+ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
5
+
6
+ export interface DaemonAttachment {
7
+ role: DaemonRole;
8
+ connectedAt: string;
9
+ lastSeenAt?: string;
10
+ probeId?: string;
11
+ instanceId?: string;
12
+ connectionId?: string;
13
+ policy?: DaemonPolicy;
14
+ tools?: string[];
15
+ authChallenge?: string;
16
+ authIssuedAt?: number;
17
+ authExpiresAt?: number;
18
+ workerOrigin?: string;
19
+ authSessionPublicKeyJson?: string;
20
+ authSessionKeyId?: string;
21
+ authCertificateExpiresAt?: number;
22
+ }
23
+
24
+ export function sanitizeDaemonAttachment(value: unknown): DaemonAttachment | undefined {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
26
+ const candidate = value as Partial<DaemonAttachment>;
27
+ if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
28
+ const policy = sanitizeDaemonPolicy(candidate.policy);
29
+ return {
30
+ role: candidate.role as DaemonRole,
31
+ connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
32
+ lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
33
+ probeId: sanitizeProbeId(candidate.probeId),
34
+ instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
35
+ connectionId: sanitizeConnectionId(candidate.connectionId),
36
+ policy,
37
+ tools: sanitizeDaemonTools(candidate.tools, policy),
38
+ ...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
39
+ };
40
+ }
41
+
42
+ function sanitizeProbeId(value: unknown): string | undefined {
43
+ return typeof value === "string" && /^probe_[A-Za-z0-9_-]{8,240}$/.test(value) ? value : undefined;
44
+ }
45
+
46
+ function sanitizeDaemonInstanceId(value: unknown): string | undefined {
47
+ return typeof value === "string" && /^daemon_[A-Za-z0-9_-]{16,96}$/.test(value) ? value : undefined;
48
+ }
49
+
50
+ function sanitizeConnectionId(value: unknown): string | undefined {
51
+ return typeof value === "string" && /^connection_[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined;
52
+ }
@@ -1,24 +1,7 @@
1
1
  import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
2
- import { sanitizeMetadataText } from "./http.ts";
3
- import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
4
- import { sanitizeDaemonChallengeAttachment, type DaemonChallenge } from "./daemon-auth.ts";
5
- export interface DaemonAttachment {
6
- role: DaemonRole;
7
- connectedAt: string;
8
- lastSeenAt?: string;
9
- probeId?: string;
10
- instanceId?: string;
11
- policy?: DaemonPolicy;
12
- tools?: string[];
13
- authChallenge?: string;
14
- authIssuedAt?: number;
15
- authExpiresAt?: number;
16
- workerOrigin?: string;
17
- authSessionPublicKeyJson?: string;
18
- authSessionKeyId?: string;
19
- authCertificateExpiresAt?: number;
20
- }
21
-
2
+ import type { DaemonPolicy } from "./policy.ts";
3
+ import type { DaemonChallenge } from "./daemon-auth.ts";
4
+ import { sanitizeDaemonAttachment, type DaemonAttachment } from "./daemon-socket-attachment.ts";
22
5
  interface WebSocketContext {
23
6
  getWebSockets(): WebSocket[];
24
7
  }
@@ -28,21 +11,7 @@ export class DaemonSocketRegistry {
28
11
  constructor(context: WebSocketContext) { this.context = context; }
29
12
 
30
13
  attachment(socket: WebSocket): DaemonAttachment | undefined {
31
- const raw = socket.deserializeAttachment();
32
- if (!raw || typeof raw !== "object") return undefined;
33
- const candidate = raw as Partial<DaemonAttachment>;
34
- if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
35
- const policy = sanitizeDaemonPolicy(candidate.policy);
36
- return {
37
- role: candidate.role as DaemonRole,
38
- connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
39
- lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
40
- probeId: sanitizeProbeId(candidate.probeId),
41
- instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
42
- policy,
43
- tools: sanitizeDaemonTools(candidate.tools, policy),
44
- ...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
45
- };
14
+ return sanitizeDaemonAttachment(socket.deserializeAttachment());
46
15
  }
47
16
 
48
17
  readyAttachment(socket: WebSocket): DaemonAttachment | undefined {
@@ -68,11 +37,13 @@ export class DaemonSocketRegistry {
68
37
  socket: WebSocket,
69
38
  challenge: DaemonChallenge,
70
39
  preflight: { sessionPublicKeyJson: string; sessionKeyId: string; certificateExpiresAt: number },
40
+ connectionId: string,
71
41
  connectedAt = new Date().toISOString(),
72
42
  ): void {
73
43
  socket.serializeAttachment({
74
44
  role: "candidate",
75
45
  connectedAt,
46
+ connectionId,
76
47
  authChallenge: challenge.challenge,
77
48
  authIssuedAt: challenge.issuedAt,
78
49
  authExpiresAt: challenge.expiresAt,
@@ -83,10 +54,11 @@ export class DaemonSocketRegistry {
83
54
  } satisfies DaemonAttachment);
84
55
  }
85
56
 
86
- beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
57
+ beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; connectionId: string; policy: DaemonPolicy; tools: string[] }): void {
87
58
  socket.serializeAttachment({
88
59
  role: "probing", connectedAt: values.connectedAt, lastSeenAt: values.connectedAt,
89
- probeId: values.probeId, instanceId: values.instanceId, policy: values.policy, tools: values.tools,
60
+ probeId: values.probeId, instanceId: values.instanceId, connectionId: values.connectionId,
61
+ policy: values.policy, tools: values.tools,
90
62
  } satisfies DaemonAttachment);
91
63
  }
92
64
 
@@ -111,10 +83,17 @@ export class DaemonSocketRegistry {
111
83
  const attachment = this.attachment(socket);
112
84
  if (!attachment) return;
113
85
  socket.serializeAttachment({
114
- role: "expired", connectedAt: attachment.connectedAt, lastSeenAt: attachment.lastSeenAt, instanceId: attachment.instanceId,
86
+ role: "expired", connectedAt: attachment.connectedAt, lastSeenAt: attachment.lastSeenAt,
87
+ instanceId: attachment.instanceId, connectionId: attachment.connectionId,
115
88
  } satisfies DaemonAttachment);
116
89
  }
117
90
 
91
+ socketForConnectionId(connectionId: string): WebSocket | undefined {
92
+ if (!connectionId) return undefined;
93
+ return this.context.getWebSockets().find((socket) =>
94
+ this.attachment(socket)?.connectionId === connectionId && socket.readyState === WebSocket.OPEN);
95
+ }
96
+
118
97
  private openSockets(role: DaemonRole): WebSocket[] {
119
98
  return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role === role && socket.readyState === WebSocket.OPEN);
120
99
  }
@@ -123,13 +102,3 @@ export class DaemonSocketRegistry {
123
102
  return Date.parse(this.attachment(socket)?.connectedAt ?? "") || 0;
124
103
  }
125
104
  }
126
-
127
- function sanitizeProbeId(value: unknown): string | undefined {
128
- if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
129
- return value;
130
- }
131
-
132
- function sanitizeDaemonInstanceId(value: unknown): string | undefined {
133
- if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return undefined;
134
- return value;
135
- }
@@ -0,0 +1,129 @@
1
+ import { daemonLastSeenMs } from "./daemon-liveness.ts";
2
+ import type { DaemonSocketRegistry } from "./daemon-sockets.ts";
3
+ import { publicWorkerToolError, WorkerToolError } from "./errors.ts";
4
+ import { workerErrorClass } from "./http.ts";
5
+ import type { PendingCallOutcome } from "./pending-call-contract.ts";
6
+ import type { McpPendingCallStore, PendingStreamCallView } from "./mcp-pending-call-store.ts";
7
+ import { streamTerminalMessage } from "./mcp-stream-dispatch.ts";
8
+ import { transformDurableStreamOutcome } from "./durable-stream-result.ts";
9
+ import type { WorkerObservability } from "./observability.ts";
10
+ import { sendWebSocketQuietly } from "./websocket-protocol.ts";
11
+
12
+ type InvalidateDaemonSocket = (
13
+ socket: WebSocket,
14
+ message: string,
15
+ closeReason: string,
16
+ errorCode?: string,
17
+ ) => Promise<void>;
18
+
19
+ type TransientPendingSnapshot = {
20
+ active: number;
21
+ detached: number;
22
+ request_keys: number;
23
+ oldest_ms: number;
24
+ by_tool: Record<string, number>;
25
+ };
26
+
27
+ export class DurableStreamCallCoordinator {
28
+ private readonly calls: McpPendingCallStore;
29
+ private readonly daemonRegistry: DaemonSocketRegistry;
30
+ private readonly observability: WorkerObservability;
31
+ private readonly maximumPendingCalls: number;
32
+ private readonly invalidateDaemonSocket: InvalidateDaemonSocket;
33
+
34
+ constructor(
35
+ calls: McpPendingCallStore,
36
+ daemonRegistry: DaemonSocketRegistry,
37
+ observability: WorkerObservability,
38
+ maximumPendingCalls: number,
39
+ invalidateDaemonSocket: InvalidateDaemonSocket,
40
+ ) {
41
+ this.calls = calls;
42
+ this.daemonRegistry = daemonRegistry;
43
+ this.observability = observability;
44
+ this.maximumPendingCalls = maximumPendingCalls;
45
+ this.invalidateDaemonSocket = invalidateDaemonSocket;
46
+ }
47
+
48
+ async snapshot(transient: TransientPendingSnapshot): Promise<Record<string, unknown>> {
49
+ const durable = await this.calls.snapshot(this.maximumPendingCalls);
50
+ const byTool = Object.assign(Object.create(null) as Record<string, number>, transient.by_tool);
51
+ for (const [tool, count] of Object.entries(durable.by_tool)) byTool[tool] = (byTool[tool] ?? 0) + count;
52
+ return {
53
+ active: transient.active + durable.active,
54
+ detached: transient.detached + durable.detached,
55
+ request_keys: transient.request_keys + durable.request_keys,
56
+ maximum: this.maximumPendingCalls,
57
+ oldest_ms: Math.max(transient.oldest_ms, durable.oldest_ms),
58
+ by_tool: byTool,
59
+ transient: transient.active,
60
+ durable_streams: durable.active,
61
+ };
62
+ }
63
+
64
+ async settle(
65
+ callId: string,
66
+ connectionId: string,
67
+ outcome: PendingCallOutcome,
68
+ knownCall?: PendingStreamCallView,
69
+ ): Promise<boolean> {
70
+ const call = knownCall ?? await this.calls.get(callId);
71
+ if (!call || call.connection_id !== connectionId) return false;
72
+ const normalized = transformDurableStreamOutcome(call, outcome);
73
+ const code = normalized.ok ? "" : publicWorkerToolError(normalized.error).code;
74
+ try {
75
+ const completed = await this.calls.complete(
76
+ callId,
77
+ connectionId,
78
+ streamTerminalMessage(call.requestId, normalized),
79
+ );
80
+ if (!completed) return false;
81
+ } catch (error) {
82
+ this.observability.event("error", "mcp.stream.persist.failed", { error_class: workerErrorClass(error) });
83
+ }
84
+ this.observability.callFinished(call.tool, code);
85
+ return true;
86
+ }
87
+
88
+ async expire(call: PendingStreamCallView): Promise<void> {
89
+ const socket = call.state === "attached" ? this.daemonRegistry.socketForConnectionId(call.connection_id) : undefined;
90
+ if (socket) {
91
+ sendWebSocketQuietly(socket, { type: "cancel_call", id: call.call_id });
92
+ const silentForMs = Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(socket));
93
+ if (!Number.isFinite(silentForMs) || silentForMs > 45_000) {
94
+ void this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
95
+ }
96
+ }
97
+ const error = call.state === "attached"
98
+ ? new WorkerToolError("timeout", `daemon tool timed out: ${call.tool}`, true)
99
+ : new WorkerToolError("unavailable", "daemon disconnected; reconnect grace expired", true);
100
+ await this.settle(call.call_id, call.connection_id, { ok: false, error }, call);
101
+ }
102
+
103
+ async expireDue(now = Date.now()): Promise<number> {
104
+ const due = await this.calls.due(now);
105
+ for (const call of due) await this.expire(call);
106
+ return due.length;
107
+ }
108
+
109
+ async cancel(requestKey: string): Promise<boolean> {
110
+ const call = await this.calls.getByRequestKey(requestKey);
111
+ if (!call) return false;
112
+ const socket = call.state === "attached" ? this.daemonRegistry.socketForConnectionId(call.connection_id) : undefined;
113
+ if (socket) sendWebSocketQuietly(socket, { type: "cancel_call", id: call.call_id });
114
+ return this.settle(
115
+ call.call_id,
116
+ call.connection_id,
117
+ { ok: false, error: new WorkerToolError("cancelled", "tool call cancelled by client") },
118
+ call,
119
+ );
120
+ }
121
+
122
+ detach(connectionId: string, graceMs: number): Promise<number> {
123
+ return this.calls.detach(connectionId, graceMs);
124
+ }
125
+
126
+ rebind(daemonInstanceId: string, connectionId: string): Promise<string[]> {
127
+ return this.calls.rebind(daemonInstanceId, connectionId);
128
+ }
129
+ }
@@ -0,0 +1,22 @@
1
+ import { decorateProjectOverview } from "./authority.ts";
2
+ import type { PendingCallOutcome } from "./pending-call-contract.ts";
3
+ import type { PendingStreamCallView } from "./mcp-pending-call-store.ts";
4
+
5
+ export function transformDurableStreamOutcome(
6
+ call: PendingStreamCallView,
7
+ outcome: PendingCallOutcome,
8
+ ): PendingCallOutcome {
9
+ if (!outcome.ok || call.transform?.kind !== "project_overview") return outcome;
10
+ try {
11
+ return {
12
+ ok: true,
13
+ value: decorateProjectOverview(outcome.value, {
14
+ accountId: call.transform.account_id,
15
+ accountVersion: call.transform.account_version,
16
+ role: call.transform.role,
17
+ }),
18
+ };
19
+ } catch (error) {
20
+ return { ok: false, error: error instanceof Error ? error : new Error("stream result transformation failed") };
21
+ }
22
+ }
@@ -143,41 +143,55 @@ export async function parseRequestBody(request: Request, limit: number): Promise
143
143
  export async function discardRequestBody(request: Pick<Request, "body" | "headers">, limit: number): Promise<{ bytes_read: number; exceeded: boolean }> {
144
144
  const boundedLimit = normalizeBodyLimit(limit);
145
145
  const declaredLength = Number(request.headers.get("content-length") ?? "0");
146
- let exceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
147
- let observed = 0;
148
- if (!request.body) return { bytes_read: 0, exceeded };
146
+ const declaredExceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
147
+ if (!request.body) return { bytes_read: 0, exceeded: declaredExceeded };
149
148
  const reader = request.body.getReader();
149
+ let observed = 0;
150
150
  try {
151
+ if (declaredExceeded) {
152
+ await cancelBodyReader(reader);
153
+ return { bytes_read: 0, exceeded: true };
154
+ }
151
155
  for (;;) {
152
156
  const { done, value } = await reader.read();
153
- if (done) break;
157
+ if (done) return { bytes_read: observed, exceeded: false };
154
158
  if (!value) continue;
155
159
  observed = Math.min(boundedLimit + 1, observed + value.byteLength);
156
- if (observed > boundedLimit) exceeded = true;
160
+ if (observed > boundedLimit) {
161
+ await cancelBodyReader(reader);
162
+ return { bytes_read: observed, exceeded: true };
163
+ }
157
164
  }
158
165
  } finally {
159
166
  reader.releaseLock();
160
167
  }
161
- return { bytes_read: observed, exceeded };
162
168
  }
163
169
 
164
170
  export async function readBoundedText(request: Request, limit: number): Promise<string> {
165
171
  const boundedLimit = normalizeBodyLimit(limit);
166
172
  const declaredLength = Number(request.headers.get("content-length") ?? "0");
167
- let exceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
173
+ const declaredExceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
168
174
  const reader = request.body?.getReader();
169
175
  if (!reader) return "";
170
176
  const chunks: Uint8Array[] = [];
171
177
  let observed = 0;
178
+ let exceeded = declaredExceeded;
172
179
  try {
173
- for (;;) {
174
- const { done, value } = await reader.read();
175
- if (done) break;
176
- if (!value) continue;
177
- const nextObserved = Math.min(boundedLimit + 1, observed + value.byteLength);
178
- if (!exceeded && observed + value.byteLength <= boundedLimit) chunks.push(value);
179
- else exceeded = true;
180
- observed = nextObserved;
180
+ if (declaredExceeded) await cancelBodyReader(reader);
181
+ else {
182
+ for (;;) {
183
+ const { done, value } = await reader.read();
184
+ if (done) break;
185
+ if (!value) continue;
186
+ const nextObserved = Math.min(boundedLimit + 1, observed + value.byteLength);
187
+ if (observed + value.byteLength <= boundedLimit) chunks.push(value);
188
+ else {
189
+ exceeded = true;
190
+ await cancelBodyReader(reader);
191
+ }
192
+ observed = nextObserved;
193
+ if (exceeded) break;
194
+ }
181
195
  }
182
196
  } finally {
183
197
  reader.releaseLock();
@@ -196,6 +210,14 @@ export async function readBoundedText(request: Request, limit: number): Promise<
196
210
  }
197
211
  }
198
212
 
213
+ async function cancelBodyReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
214
+ try {
215
+ await reader.cancel("request body limit reached");
216
+ } catch {
217
+ // Cancellation is cleanup after the size decision; it must not replace the bounded response.
218
+ }
219
+ }
220
+
199
221
  function normalizeBodyLimit(value: number): number {
200
222
  const parsed = Number(value);
201
223
  return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;