dsh-mobile 0.7.1 → 0.8.1
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.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTORS.md +2 -2
- package/README.en.md +35 -12
- package/README.md +37 -12
- package/SECURITY.md +6 -0
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/apps/mobile/README.md +10 -6
- package/apps/mobile/README.zh-CN.md +10 -6
- package/apps/mobile/release.json +3 -3
- package/assets/mobile-push/icon-180.png +0 -0
- package/assets/mobile-push/icon-192.png +0 -0
- package/assets/mobile-push/icon-512.png +0 -0
- package/docs/MOBILE_PLUGIN_ADAPTATION.en.md +18 -0
- package/docs/MOBILE_PLUGIN_ADAPTATION.md +18 -0
- package/docs/MOBILE_PLUGIN_BASELINE.en.md +117 -0
- package/docs/MOBILE_PLUGIN_BASELINE.md +117 -0
- package/docs/README.md +3 -0
- package/docs/SLOW_CONNECTIONS.en.md +7 -3
- package/docs/SLOW_CONNECTIONS.md +7 -3
- package/docs/WEB_PUSH.en.md +118 -0
- package/docs/WEB_PUSH.md +118 -0
- package/lib/cli.js.map +1 -1
- package/lib/client.js +1250 -17
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +129 -1
- package/lib/index.mjs +3854 -106
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js +23 -4
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +19 -5
- package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { X509Certificate, createECDH, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -10,7 +10,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
10
10
|
import { createSocket } from "node:dgram";
|
|
11
11
|
import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
|
|
12
12
|
import { STATUS_CODES, createServer as createServer$1, request } from "node:http";
|
|
13
|
-
import { createServer as createServer$2, request as request$1 } from "node:https";
|
|
13
|
+
import https, { createServer as createServer$2, request as request$1 } from "node:https";
|
|
14
14
|
import { Readable, Transform, finished } from "node:stream";
|
|
15
15
|
import { finished as finished$1, pipeline } from "node:stream/promises";
|
|
16
16
|
import { promisify } from "node:util";
|
|
@@ -26,6 +26,8 @@ import { generate } from "selfsigned";
|
|
|
26
26
|
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
|
|
27
27
|
import { lookup } from "node:dns/promises";
|
|
28
28
|
import { constants } from "node:fs";
|
|
29
|
+
import { z as z$1 } from "zod";
|
|
30
|
+
import webPush from "web-push";
|
|
29
31
|
//#region src/access.ts
|
|
30
32
|
/** Stable error categories converted to deliberately terse HTTP responses. */
|
|
31
33
|
var AccessError = class extends Error {
|
|
@@ -112,6 +114,7 @@ var AccessController = class {
|
|
|
112
114
|
pairPending = false;
|
|
113
115
|
sessions = /* @__PURE__ */ new Map();
|
|
114
116
|
sessionEndedListeners = /* @__PURE__ */ new Set();
|
|
117
|
+
devicesRemovedListeners = /* @__PURE__ */ new Set();
|
|
115
118
|
mutation = Promise.resolve();
|
|
116
119
|
initialized = false;
|
|
117
120
|
closing = false;
|
|
@@ -166,6 +169,13 @@ var AccessController = class {
|
|
|
166
169
|
});
|
|
167
170
|
for (const listener of this.sessionEndedListeners) listener(authorization, reason);
|
|
168
171
|
}
|
|
172
|
+
emitDevicesRemoved(ids, reason) {
|
|
173
|
+
const notificationIds = Object.freeze([...ids]);
|
|
174
|
+
for (const listener of [...this.devicesRemovedListeners]) try {
|
|
175
|
+
const result = listener(notificationIds, reason);
|
|
176
|
+
Promise.resolve(result).catch(() => void 0);
|
|
177
|
+
} catch (_error) {}
|
|
178
|
+
}
|
|
169
179
|
removeSession(key, reason) {
|
|
170
180
|
const session = this.sessions.get(key);
|
|
171
181
|
if (session === void 0) return;
|
|
@@ -360,7 +370,11 @@ var AccessController = class {
|
|
|
360
370
|
if (next.length === this.devices.length) return false;
|
|
361
371
|
await this.store.save(this.snapshot(next));
|
|
362
372
|
this.devices = next;
|
|
363
|
-
|
|
373
|
+
try {
|
|
374
|
+
for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
|
|
375
|
+
} finally {
|
|
376
|
+
this.emitDevicesRemoved([deviceId], "revoked");
|
|
377
|
+
}
|
|
364
378
|
return true;
|
|
365
379
|
});
|
|
366
380
|
}
|
|
@@ -368,12 +382,31 @@ var AccessController = class {
|
|
|
368
382
|
async resetDevices() {
|
|
369
383
|
this.requireInitialized();
|
|
370
384
|
await this.exclusive(async () => {
|
|
385
|
+
const removedIds = this.devices.map((device) => device.id);
|
|
371
386
|
await this.store.save(this.snapshot([]));
|
|
372
387
|
this.devices = [];
|
|
373
|
-
for (const key of [...this.sessions.keys()]) this.removeSession(key, "revoked");
|
|
374
388
|
this.pairingWindow = void 0;
|
|
389
|
+
try {
|
|
390
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "revoked");
|
|
391
|
+
} finally {
|
|
392
|
+
this.emitDevicesRemoved(removedIds, "reset");
|
|
393
|
+
}
|
|
375
394
|
});
|
|
376
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Read fresh persistent-device authorization metadata without changing credentials,
|
|
398
|
+
* last-seen state, Session state, or the backing store.
|
|
399
|
+
*/
|
|
400
|
+
async authorizedDevices() {
|
|
401
|
+
this.requireInitialized();
|
|
402
|
+
const snapshot = await this.store.load();
|
|
403
|
+
this.requireInitialized();
|
|
404
|
+
const now = this.now();
|
|
405
|
+
return Object.freeze(snapshot.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).map((device) => Object.freeze({
|
|
406
|
+
id: device.id,
|
|
407
|
+
expiresAt: device.expiresAt
|
|
408
|
+
})));
|
|
409
|
+
}
|
|
377
410
|
/** Safe metadata for the loopback administration surface. */
|
|
378
411
|
listDevices() {
|
|
379
412
|
this.requireInitialized();
|
|
@@ -399,6 +432,14 @@ var AccessController = class {
|
|
|
399
432
|
this.sessionEndedListeners.delete(listener);
|
|
400
433
|
};
|
|
401
434
|
}
|
|
435
|
+
/** Subscribe to durably committed removal of persistent-device authorization. */
|
|
436
|
+
onDevicesRemoved(listener) {
|
|
437
|
+
if (this.closing) throw new Error("access controller is not available");
|
|
438
|
+
this.devicesRemovedListeners.add(listener);
|
|
439
|
+
return () => {
|
|
440
|
+
this.devicesRemovedListeners.delete(listener);
|
|
441
|
+
};
|
|
442
|
+
}
|
|
402
443
|
/** Stop new operations, drain durable mutations, then clear volatile credentials. */
|
|
403
444
|
close() {
|
|
404
445
|
if (this.closeTask !== void 0) return this.closeTask;
|
|
@@ -409,9 +450,13 @@ var AccessController = class {
|
|
|
409
450
|
async finishClose() {
|
|
410
451
|
await this.mutation;
|
|
411
452
|
this.pairingWindow = void 0;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
453
|
+
try {
|
|
454
|
+
for (const key of [...this.sessions.keys()]) this.removeSession(key, "expired");
|
|
455
|
+
} finally {
|
|
456
|
+
this.sessionEndedListeners.clear();
|
|
457
|
+
this.devicesRemovedListeners.clear();
|
|
458
|
+
this.initialized = false;
|
|
459
|
+
}
|
|
415
460
|
}
|
|
416
461
|
/** Bounded volatile-state metrics for tests and local status. */
|
|
417
462
|
metrics() {
|
|
@@ -967,7 +1012,7 @@ const Config = z.object({
|
|
|
967
1012
|
mobileCompatibilityFile: z.string().hidden(),
|
|
968
1013
|
excludedClientModules: z.array(String).default([]),
|
|
969
1014
|
websocketCompression: z.object({
|
|
970
|
-
paths: z.array(String).default([]),
|
|
1015
|
+
paths: z.array(String).default(["/api/remote.mux"]),
|
|
971
1016
|
maxMessageBytes: z.natural(),
|
|
972
1017
|
maxQueuedBytes: z.natural(),
|
|
973
1018
|
thresholdBytes: z.natural(),
|
|
@@ -1005,6 +1050,8 @@ function integer(value, name, fallback, minimum, maximum) {
|
|
|
1005
1050
|
if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`${name} must be an integer from ${String(minimum)} through ${String(maximum)}`);
|
|
1006
1051
|
return resolved;
|
|
1007
1052
|
}
|
|
1053
|
+
/** Live paths the mobile gateway compresses by default; an explicit `paths: []` opts out. */
|
|
1054
|
+
const DEFAULT_WEBSOCKET_COMPRESSION_PATHS = Object.freeze(["/api/remote.mux"]);
|
|
1008
1055
|
function parseWebSocketCompression(raw) {
|
|
1009
1056
|
if (raw !== void 0 && (typeof raw !== "object" || raw === null || Array.isArray(raw))) throw new Error("websocketCompression must be an object");
|
|
1010
1057
|
const value = raw ?? {};
|
|
@@ -1012,7 +1059,7 @@ function parseWebSocketCompression(raw) {
|
|
|
1012
1059
|
const maxQueuedBytes = integer(value.maxQueuedBytes, "websocketCompression.maxQueuedBytes", 67108864, 1024, 268435456);
|
|
1013
1060
|
if (maxQueuedBytes < maxMessageBytes + 14) throw new Error("websocketCompression.maxQueuedBytes must cover maxMessageBytes and the frame header");
|
|
1014
1061
|
return Object.freeze({
|
|
1015
|
-
paths: Object.freeze(normalizeWebSocketPaths(value.paths ??
|
|
1062
|
+
paths: Object.freeze(normalizeWebSocketPaths(value.paths ?? DEFAULT_WEBSOCKET_COMPRESSION_PATHS)),
|
|
1016
1063
|
maxMessageBytes,
|
|
1017
1064
|
maxQueuedBytes,
|
|
1018
1065
|
thresholdBytes: integer(value.thresholdBytes, "websocketCompression.thresholdBytes", 1024, 0, maxMessageBytes),
|
|
@@ -1500,6 +1547,15 @@ var ClientModulePreferenceStore = class {
|
|
|
1500
1547
|
};
|
|
1501
1548
|
//#endregion
|
|
1502
1549
|
//#region src/control.ts
|
|
1550
|
+
/** A configuration change failed; recovered distinguishes a restored runtime from an unknown configuration. */
|
|
1551
|
+
var MobileAccessReconfigurationError = class extends Error {
|
|
1552
|
+
recovered;
|
|
1553
|
+
constructor(recovered, cause) {
|
|
1554
|
+
super(recovered ? "mobile access configuration restored" : "mobile access configuration requires recovery", { cause });
|
|
1555
|
+
this.recovered = recovered;
|
|
1556
|
+
this.name = "MobileAccessReconfigurationError";
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1503
1559
|
/** Keeps one runtime aligned with a changing network selection. */
|
|
1504
1560
|
var FollowingMobileAccessRuntime = class {
|
|
1505
1561
|
select;
|
|
@@ -1690,6 +1746,36 @@ var MobileAccessGatewayController = class {
|
|
|
1690
1746
|
else await this.disable();
|
|
1691
1747
|
});
|
|
1692
1748
|
}
|
|
1749
|
+
/** Change configuration with the runtime stopped, without changing its durable enabled preference. */
|
|
1750
|
+
reconfigure(change, restore) {
|
|
1751
|
+
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("mobile-access control is closing"));
|
|
1752
|
+
return this.enqueue(async () => {
|
|
1753
|
+
if (!this.initialized) throw new Error("mobile-access control is not initialized");
|
|
1754
|
+
const previous = this.runtime;
|
|
1755
|
+
if (previous !== void 0) try {
|
|
1756
|
+
await previous.close();
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
throw new MobileAccessReconfigurationError(false, error);
|
|
1759
|
+
}
|
|
1760
|
+
this.runtime = void 0;
|
|
1761
|
+
try {
|
|
1762
|
+
const value = await change();
|
|
1763
|
+
if (previous !== void 0) this.runtime = await this.startRuntime();
|
|
1764
|
+
return {
|
|
1765
|
+
value,
|
|
1766
|
+
running: previous !== void 0
|
|
1767
|
+
};
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
try {
|
|
1770
|
+
await restore();
|
|
1771
|
+
if (previous !== void 0) this.runtime = await this.startRuntime();
|
|
1772
|
+
} catch (restoreError) {
|
|
1773
|
+
throw new MobileAccessReconfigurationError(false, new AggregateError([error, restoreError]));
|
|
1774
|
+
}
|
|
1775
|
+
throw new MobileAccessReconfigurationError(true, error);
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1693
1779
|
/** Stop the runtime after earlier transitions without changing the restart preference. */
|
|
1694
1780
|
close() {
|
|
1695
1781
|
if (this.closeTask !== void 0) return this.closeTask;
|
|
@@ -2041,6 +2127,7 @@ function requestPathCategory(path) {
|
|
|
2041
2127
|
default:
|
|
2042
2128
|
if (path.startsWith("/api/")) return "/api/*";
|
|
2043
2129
|
if (path.startsWith("/mobile-access/extensions/")) return "/mobile-access/extensions/*";
|
|
2130
|
+
if (/^\/mobile-access\/compat-[a-f\d]{64}\.js$/u.test(path)) return "/mobile-access/compat.js";
|
|
2044
2131
|
if (path.startsWith("/mobile-access/")) return "/mobile-access/*";
|
|
2045
2132
|
return "/*";
|
|
2046
2133
|
}
|
|
@@ -2518,6 +2605,22 @@ function renderClientModuleRecoveryScript(locale) {
|
|
|
2518
2605
|
//#region src/mobile-compat-bootstrap.ts
|
|
2519
2606
|
const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
|
|
2520
2607
|
/**
|
|
2608
|
+
* Name the compatibility bundle by content.
|
|
2609
|
+
*
|
|
2610
|
+
* The plain path has no revision, so a client that stores it must revalidate on
|
|
2611
|
+
* every page load. A content-addressed path can be served `immutable` instead: the
|
|
2612
|
+
* URL changes exactly when the bundle does, and a stale URL is refused rather than
|
|
2613
|
+
* answered with different bytes.
|
|
2614
|
+
*/
|
|
2615
|
+
function mobileCompatibilityRevisionPath(digest) {
|
|
2616
|
+
return `${AUTH_PREFIX}/compat-${digest}.js`;
|
|
2617
|
+
}
|
|
2618
|
+
const MOBILE_COMPAT_REVISION = new RegExp(`^${AUTH_PREFIX}/compat-([a-f\\d]{64})\\.js$`, "u");
|
|
2619
|
+
/** The content digest named by a revisioned compatibility path, if this is one. */
|
|
2620
|
+
function mobileCompatibilityRevision(pathname) {
|
|
2621
|
+
return MOBILE_COMPAT_REVISION.exec(pathname)?.[1];
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2521
2624
|
* Read one attribute out of a tag body.
|
|
2522
2625
|
*
|
|
2523
2626
|
* A plain `/\snonce\s*=/` search can be fooled by a value that merely contains the
|
|
@@ -2550,17 +2653,19 @@ function ensureMobileGatewayMarker(html) {
|
|
|
2550
2653
|
* Returns the HTML unchanged when there is no executable script to anchor to.
|
|
2551
2654
|
* The bundle supplies missing browser APIs before the page's scripts execute.
|
|
2552
2655
|
*/
|
|
2553
|
-
function ensureMobileCompatibility(html) {
|
|
2656
|
+
function ensureMobileCompatibility(html, compatPath = MOBILE_COMPAT_PATH) {
|
|
2554
2657
|
const comments = commentRanges(html);
|
|
2555
2658
|
const isInert = (at) => comments.some(([start, end]) => at >= start && at < end);
|
|
2556
2659
|
const scripts = [...html.matchAll(/<script\b[^>]*>/giu)].filter((match) => !isInert(match.index));
|
|
2557
2660
|
if (scripts.length === 0) return html;
|
|
2558
|
-
if (scripts.some((match) =>
|
|
2661
|
+
if (scripts.some((match) => {
|
|
2662
|
+
const src = attributeValue(match[0], "src");
|
|
2663
|
+
return src !== void 0 && (src === MOBILE_COMPAT_PATH || mobileCompatibilityRevision(src) !== void 0);
|
|
2664
|
+
})) return html;
|
|
2559
2665
|
const first = scripts[0];
|
|
2560
2666
|
if (first === void 0) return html;
|
|
2561
2667
|
const nonce = attributeValue(first[0], "nonce")?.replace(/["'<>\s]/gu, "") ?? "";
|
|
2562
|
-
const
|
|
2563
|
-
const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
|
|
2668
|
+
const script = `<script src="${compatPath}"${nonce === "" ? "" : ` nonce="${nonce}"`}><\/script>`;
|
|
2564
2669
|
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
2565
2670
|
}
|
|
2566
2671
|
/** Maximum sizes enforced when reading local extension files. */
|
|
@@ -5209,6 +5314,67 @@ function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
|
|
|
5209
5314
|
return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
|
|
5210
5315
|
}
|
|
5211
5316
|
//#endregion
|
|
5317
|
+
//#region src/mobile-push/assets.ts
|
|
5318
|
+
const ICON_FILES = Object.freeze({
|
|
5319
|
+
"/mobile-access/push/icon-180.png": "icon-180.png",
|
|
5320
|
+
"/mobile-access/push/icon-192.png": "icon-192.png",
|
|
5321
|
+
"/mobile-access/push/icon-512.png": "icon-512.png"
|
|
5322
|
+
});
|
|
5323
|
+
const MANIFEST_PATH = "/mobile-access/push/manifest.webmanifest";
|
|
5324
|
+
const MANIFEST = JSON.stringify({
|
|
5325
|
+
id: "/",
|
|
5326
|
+
start_url: "/",
|
|
5327
|
+
scope: "/",
|
|
5328
|
+
display: "standalone",
|
|
5329
|
+
icons: [
|
|
5330
|
+
{
|
|
5331
|
+
src: "/mobile-access/push/icon-180.png",
|
|
5332
|
+
sizes: "180x180",
|
|
5333
|
+
type: "image/png"
|
|
5334
|
+
},
|
|
5335
|
+
{
|
|
5336
|
+
src: "/mobile-access/push/icon-192.png",
|
|
5337
|
+
sizes: "192x192",
|
|
5338
|
+
type: "image/png"
|
|
5339
|
+
},
|
|
5340
|
+
{
|
|
5341
|
+
src: "/mobile-access/push/icon-512.png",
|
|
5342
|
+
sizes: "512x512",
|
|
5343
|
+
type: "image/png"
|
|
5344
|
+
}
|
|
5345
|
+
]
|
|
5346
|
+
});
|
|
5347
|
+
/** Create a fixed-allowlist loader rooted at a package root containing assets/mobile-push. */
|
|
5348
|
+
function createMobilePushAssetLoader(packageRoot) {
|
|
5349
|
+
const root = resolve(packageRoot);
|
|
5350
|
+
return async (path) => {
|
|
5351
|
+
if (path === MANIFEST_PATH) return {
|
|
5352
|
+
contentType: "application/manifest+json; charset=utf-8",
|
|
5353
|
+
body: MANIFEST
|
|
5354
|
+
};
|
|
5355
|
+
const filename = ICON_FILES[path];
|
|
5356
|
+
if (!filename) return void 0;
|
|
5357
|
+
return {
|
|
5358
|
+
contentType: "image/png",
|
|
5359
|
+
body: await readFile(join(root, "assets", "mobile-push", filename))
|
|
5360
|
+
};
|
|
5361
|
+
};
|
|
5362
|
+
}
|
|
5363
|
+
const packageRoot = dirname(createRequire(import.meta.url).resolve("dsh-mobile/package.json"));
|
|
5364
|
+
const loadFromPackage = createMobilePushAssetLoader(packageRoot);
|
|
5365
|
+
/** Return only a known push manifest/icon; optional packageRoot enables explicit host injection. */
|
|
5366
|
+
function mobilePushAsset(path, root = packageRoot) {
|
|
5367
|
+
return root === packageRoot ? loadFromPackage(path) : createMobilePushAssetLoader(root)(path);
|
|
5368
|
+
}
|
|
5369
|
+
//#endregion
|
|
5370
|
+
//#region src/mobile-push/installation-id.ts
|
|
5371
|
+
/** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
|
|
5372
|
+
const INSTALLATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
5373
|
+
/** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
|
|
5374
|
+
function isPushInstallationId(value) {
|
|
5375
|
+
return typeof value === "string" && INSTALLATION_ID.test(value);
|
|
5376
|
+
}
|
|
5377
|
+
//#endregion
|
|
5212
5378
|
//#region src/gateway.ts
|
|
5213
5379
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
5214
5380
|
const MAX_HEADER_BYTES = 16384;
|
|
@@ -5220,6 +5386,93 @@ const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
|
|
|
5220
5386
|
const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
|
|
5221
5387
|
const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
|
|
5222
5388
|
const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
|
|
5389
|
+
const MOBILE_PUSH_CONFIG_PATH = `${AUTH_PREFIX}/push/config`;
|
|
5390
|
+
const MOBILE_PUSH_SUBSCRIPTION_PATH = `${AUTH_PREFIX}/push/subscription`;
|
|
5391
|
+
const MOBILE_PUSH_PREFERENCES_PATH = `${AUTH_PREFIX}/push/preferences`;
|
|
5392
|
+
const MOBILE_PUSH_DISABLE_PATH = `${AUTH_PREFIX}/push/disable`;
|
|
5393
|
+
const MOBILE_PUSH_TEST_PATH = `${AUTH_PREFIX}/push/test`;
|
|
5394
|
+
const MOBILE_PUSH_RESOLVE_PATH = `${AUTH_PREFIX}/push/resolve`;
|
|
5395
|
+
const MOBILE_PUSH_PREFERENCE_KEYS = [
|
|
5396
|
+
"question",
|
|
5397
|
+
"plan",
|
|
5398
|
+
"approval",
|
|
5399
|
+
"response",
|
|
5400
|
+
"goal",
|
|
5401
|
+
"failure"
|
|
5402
|
+
];
|
|
5403
|
+
const DEFAULT_PUSH_PREFERENCES = Object.freeze({
|
|
5404
|
+
question: true,
|
|
5405
|
+
plan: true,
|
|
5406
|
+
approval: true,
|
|
5407
|
+
response: true,
|
|
5408
|
+
goal: true,
|
|
5409
|
+
failure: true
|
|
5410
|
+
});
|
|
5411
|
+
function isRecord$1(value) {
|
|
5412
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5413
|
+
}
|
|
5414
|
+
function hasOnlyKeys(value, keys) {
|
|
5415
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
5416
|
+
}
|
|
5417
|
+
function exactKeys$1(value, keys) {
|
|
5418
|
+
return Object.keys(value).length === keys.length && hasOnlyKeys(value, keys);
|
|
5419
|
+
}
|
|
5420
|
+
function parseInstallationId(value) {
|
|
5421
|
+
if (!isPushInstallationId(value)) throw new HttpError(400, "bad_request");
|
|
5422
|
+
return value;
|
|
5423
|
+
}
|
|
5424
|
+
function parsePushPreferences(value) {
|
|
5425
|
+
if (!isRecord$1(value) || !exactKeys$1(value, MOBILE_PUSH_PREFERENCE_KEYS) || !MOBILE_PUSH_PREFERENCE_KEYS.every((key) => typeof value[key] === "boolean")) throw new HttpError(400, "bad_request");
|
|
5426
|
+
return Object.freeze({
|
|
5427
|
+
question: value.question,
|
|
5428
|
+
plan: value.plan,
|
|
5429
|
+
approval: value.approval,
|
|
5430
|
+
response: value.response,
|
|
5431
|
+
goal: value.goal,
|
|
5432
|
+
failure: value.failure
|
|
5433
|
+
});
|
|
5434
|
+
}
|
|
5435
|
+
function parseGeneration(value, allowZero) {
|
|
5436
|
+
if (!Number.isSafeInteger(value) || Number(value) < (allowZero ? 0 : 1)) throw new HttpError(400, "bad_request");
|
|
5437
|
+
return Number(value);
|
|
5438
|
+
}
|
|
5439
|
+
function parsePushEnrollment(value) {
|
|
5440
|
+
if (!exactKeys$1(value, [
|
|
5441
|
+
"installationId",
|
|
5442
|
+
"subscription",
|
|
5443
|
+
"locale",
|
|
5444
|
+
"preferences",
|
|
5445
|
+
"keyFingerprint",
|
|
5446
|
+
"ownerGeneration",
|
|
5447
|
+
"registrationGeneration"
|
|
5448
|
+
]) || !isRecord$1(value.subscription) || !exactKeys$1(value.subscription, ["endpoint", "keys"]) || !isRecord$1(value.subscription.keys) || !exactKeys$1(value.subscription.keys, ["p256dh", "auth"]) || value.locale !== "en" && value.locale !== "zh-CN" && value.locale !== "it" || typeof value.keyFingerprint !== "string" || value.keyFingerprint.length === 0 || value.keyFingerprint.length > 128) throw new HttpError(400, "bad_request");
|
|
5449
|
+
const installationId = parseInstallationId(value.installationId);
|
|
5450
|
+
const preferences = parsePushPreferences(value.preferences);
|
|
5451
|
+
const subscription = value.subscription;
|
|
5452
|
+
if (typeof subscription.endpoint !== "string" || typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") throw new HttpError(400, "bad_request");
|
|
5453
|
+
return {
|
|
5454
|
+
installationId,
|
|
5455
|
+
subscription,
|
|
5456
|
+
locale: value.locale,
|
|
5457
|
+
preferences,
|
|
5458
|
+
keyFingerprint: value.keyFingerprint,
|
|
5459
|
+
ownerGeneration: parseGeneration(value.ownerGeneration, true),
|
|
5460
|
+
registrationGeneration: parseGeneration(value.registrationGeneration, true)
|
|
5461
|
+
};
|
|
5462
|
+
}
|
|
5463
|
+
function safePushFailure(error) {
|
|
5464
|
+
if (error instanceof HttpError) return error;
|
|
5465
|
+
const message = error instanceof Error ? error.message : "";
|
|
5466
|
+
if (message.includes("stale push mutation generation")) return new HttpError(409, "stale_generation");
|
|
5467
|
+
if (message.includes("conflict") || message.includes("already owned")) return new HttpError(409, "push_conflict");
|
|
5468
|
+
if (message === "push_unauthorized") return new HttpError(401, "authentication_failed");
|
|
5469
|
+
if (message === "push_test_rate_limited") return new HttpError(429, "rate_limited");
|
|
5470
|
+
if (message === "push_test_unavailable") return new HttpError(503, "push_unavailable");
|
|
5471
|
+
if (message === "push_subscription_invalid" || message === "push_endpoint_invalid" || message.startsWith("invalid subscription")) return new HttpError(400, "bad_request");
|
|
5472
|
+
if (message === "push_unavailable") return new HttpError(503, "push_unavailable");
|
|
5473
|
+
if (message === "push_test_failed") return new HttpError(502, "push_test_failed");
|
|
5474
|
+
return new HttpError(500, "internal_error");
|
|
5475
|
+
}
|
|
5223
5476
|
const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
|
|
5224
5477
|
const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
|
|
5225
5478
|
/**
|
|
@@ -5674,6 +5927,30 @@ function rewriteMobileApplicationPreloads(html, replacements) {
|
|
|
5674
5927
|
return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
|
|
5675
5928
|
});
|
|
5676
5929
|
}
|
|
5930
|
+
const MOBILE_PUSH_HEAD_LINKS = [
|
|
5931
|
+
"<link rel=\"manifest\" href=\"/mobile-access/push/manifest.webmanifest\">",
|
|
5932
|
+
"<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/mobile-access/push/icon-180.png\">",
|
|
5933
|
+
"<link rel=\"apple-touch-icon\" sizes=\"192x192\" href=\"/mobile-access/push/icon-192.png\">",
|
|
5934
|
+
"<link rel=\"apple-touch-icon\" sizes=\"512x512\" href=\"/mobile-access/push/icon-512.png\">"
|
|
5935
|
+
].join("");
|
|
5936
|
+
function replaceDedicatedMobilePushLinks(html) {
|
|
5937
|
+
if (/<head\b[^>]*>/iu.exec(html) === null) return html;
|
|
5938
|
+
const linkPattern = /<link\b[^>]*>/giu;
|
|
5939
|
+
const remove = [];
|
|
5940
|
+
for (const match of html.matchAll(linkPattern)) {
|
|
5941
|
+
const rel = (preloadAttribute(match[0], "rel") ?? "").toLowerCase().split(/\s+/u);
|
|
5942
|
+
if (rel.includes("manifest") || rel.includes("apple-touch-icon")) remove.push({
|
|
5943
|
+
start: match.index,
|
|
5944
|
+
end: match.index + match[0].length
|
|
5945
|
+
});
|
|
5946
|
+
}
|
|
5947
|
+
let cleaned = html;
|
|
5948
|
+
for (const range of remove.reverse()) cleaned = `${cleaned.slice(0, range.start)}${cleaned.slice(range.end)}`;
|
|
5949
|
+
const head = /<head\b[^>]*>/iu.exec(cleaned);
|
|
5950
|
+
if (head?.index === void 0) throw new Error("dedicated mobile document has no head element");
|
|
5951
|
+
const position = head.index + head[0].length;
|
|
5952
|
+
return `${cleaned.slice(0, position)}${MOBILE_PUSH_HEAD_LINKS}${cleaned.slice(position)}`;
|
|
5953
|
+
}
|
|
5677
5954
|
function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
5678
5955
|
const plan = parseMobileBootPlan(html, options.excludedClientModules);
|
|
5679
5956
|
const projected = projectMobileBootGraph(plan, options);
|
|
@@ -5681,7 +5958,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
5681
5958
|
const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${transportBootstrap}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
|
|
5682
5959
|
const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
|
|
5683
5960
|
return Object.freeze({
|
|
5684
|
-
html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements)))),
|
|
5961
|
+
html: replaceDedicatedMobilePushLinks(ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements), options.compatPath)))),
|
|
5685
5962
|
batches: projected.batches,
|
|
5686
5963
|
graphTransport: projected.graphTransport
|
|
5687
5964
|
});
|
|
@@ -6141,6 +6418,7 @@ var MobileAccessGateway = class {
|
|
|
6141
6418
|
extraWebSocketPaths;
|
|
6142
6419
|
blockedUpgradeLog;
|
|
6143
6420
|
onDiscoveryDegraded;
|
|
6421
|
+
pushGateway;
|
|
6144
6422
|
access;
|
|
6145
6423
|
listenerTlsEnabled;
|
|
6146
6424
|
tlsEnabled;
|
|
@@ -6188,6 +6466,10 @@ var MobileAccessGateway = class {
|
|
|
6188
6466
|
started = false;
|
|
6189
6467
|
closeTask;
|
|
6190
6468
|
removeSessionListener;
|
|
6469
|
+
removePushDeviceListener;
|
|
6470
|
+
detachPushOrigin;
|
|
6471
|
+
pushDeviceRevocations = /* @__PURE__ */ new Set();
|
|
6472
|
+
pushTestsInFlight = /* @__PURE__ */ new Set();
|
|
6191
6473
|
removeExtensionContentListener;
|
|
6192
6474
|
renewFailureLimiter;
|
|
6193
6475
|
renewDeviceLimiter;
|
|
@@ -6207,13 +6489,14 @@ var MobileAccessGateway = class {
|
|
|
6207
6489
|
httpFailures;
|
|
6208
6490
|
apiLatency;
|
|
6209
6491
|
upstreamAuthDiagnostics;
|
|
6210
|
-
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog) {
|
|
6492
|
+
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog, pushGateway) {
|
|
6211
6493
|
this.config = config;
|
|
6212
6494
|
this.extensions = extensions;
|
|
6213
6495
|
this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
|
|
6214
6496
|
this.extraWebSocketPaths = extraWebSocketPaths;
|
|
6215
6497
|
this.blockedUpgradeLog = blockedUpgradeLog;
|
|
6216
6498
|
this.onDiscoveryDegraded = onDiscoveryDegraded;
|
|
6499
|
+
this.pushGateway = pushGateway;
|
|
6217
6500
|
this.authFailures = new AuthFailureRecorder(authFailureLog);
|
|
6218
6501
|
this.httpFailures = new HttpFailureRecorder(httpFailureLog);
|
|
6219
6502
|
this.apiLatency = new ApiLatencyRecorder(apiLatencyLog);
|
|
@@ -6239,6 +6522,15 @@ var MobileAccessGateway = class {
|
|
|
6239
6522
|
this.renewDeviceLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxDevices);
|
|
6240
6523
|
this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
6241
6524
|
this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
|
|
6525
|
+
this.removePushDeviceListener = this.access.onDevicesRemoved((ids) => {
|
|
6526
|
+
const invalidate = this.pushGateway?.invalidateDevices;
|
|
6527
|
+
if (invalidate === void 0) return;
|
|
6528
|
+
const mutation = Promise.resolve().then(() => invalidate(config.stateFile, ids)).catch(() => {
|
|
6529
|
+
process.emitWarning("Unable to remove revoked device notification state", { code: "DSH_MOBILE_PUSH_REVOKE_FAILED" });
|
|
6530
|
+
});
|
|
6531
|
+
this.pushDeviceRevocations.add(mutation);
|
|
6532
|
+
mutation.finally(() => this.pushDeviceRevocations.delete(mutation));
|
|
6533
|
+
});
|
|
6242
6534
|
this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
|
|
6243
6535
|
for (const view of this.mobileDocumentViews.values()) if (view.sessionKey === authorization.sessionKey) this.removeMobileDocumentView(view);
|
|
6244
6536
|
if (reason === "revoked") {
|
|
@@ -6334,6 +6626,15 @@ var MobileAccessGateway = class {
|
|
|
6334
6626
|
if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
|
|
6335
6627
|
this.listenerPort = address.port;
|
|
6336
6628
|
this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
|
|
6629
|
+
if (this.pushGateway?.attachOrigin !== void 0) try {
|
|
6630
|
+
this.detachPushOrigin = this.pushGateway.attachOrigin({
|
|
6631
|
+
scopeId: this.config.stateFile,
|
|
6632
|
+
origin: this.address().origin,
|
|
6633
|
+
authorizedDevices: () => this.access.authorizedDevices()
|
|
6634
|
+
});
|
|
6635
|
+
} catch {
|
|
6636
|
+
process.emitWarning("Mobile push origin attachment failed; Mobile access remains available", { code: "DSH_MOBILE_PUSH_ORIGIN_ATTACH_FAILED" });
|
|
6637
|
+
}
|
|
6337
6638
|
if (this.config.discovery) await this.startDiscovery(address.port);
|
|
6338
6639
|
await this.pollLegacyCustomChanges();
|
|
6339
6640
|
this.extensionChangeTimer = setInterval(() => {
|
|
@@ -6465,6 +6766,10 @@ var MobileAccessGateway = class {
|
|
|
6465
6766
|
}), "utf8");
|
|
6466
6767
|
}
|
|
6467
6768
|
async closeFailedStart() {
|
|
6769
|
+
const detachPushOrigin = this.detachPushOrigin;
|
|
6770
|
+
this.detachPushOrigin = void 0;
|
|
6771
|
+
detachPushOrigin?.();
|
|
6772
|
+
this.removePushDeviceListener();
|
|
6468
6773
|
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
6469
6774
|
this.extensionChangeTimer = void 0;
|
|
6470
6775
|
this.removeExtensionContentListener();
|
|
@@ -6696,10 +7001,111 @@ var MobileAccessGateway = class {
|
|
|
6696
7001
|
}, this.tlsEnabled);
|
|
6697
7002
|
});
|
|
6698
7003
|
}
|
|
7004
|
+
async requireFreshPushOwner(authorization) {
|
|
7005
|
+
const owner = {
|
|
7006
|
+
scopeId: this.config.stateFile,
|
|
7007
|
+
deviceId: authorization.deviceId,
|
|
7008
|
+
origin: this.address().origin
|
|
7009
|
+
};
|
|
7010
|
+
if (!(await this.access.authorizedDevices()).some((device) => device.id === owner.deviceId && device.expiresAt > Date.now())) throw new HttpError(401, "authentication_failed");
|
|
7011
|
+
return owner;
|
|
7012
|
+
}
|
|
7013
|
+
async handleMobilePushRequest(target, request, response, authorization) {
|
|
7014
|
+
const method = request.method ?? "";
|
|
7015
|
+
if (target.decodedPathname === MOBILE_PUSH_CONFIG_PATH) {
|
|
7016
|
+
if (method !== "GET") throw new HttpError(405, "method_not_allowed");
|
|
7017
|
+
const params = new URLSearchParams(target.search);
|
|
7018
|
+
const values = params.getAll("installationId");
|
|
7019
|
+
if (params.size !== 1 || values.length !== 1) throw new HttpError(400, "bad_request");
|
|
7020
|
+
const installationId = parseInstallationId(values[0]);
|
|
7021
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
7022
|
+
sendJson(response, 200, this.pushGateway === void 0 ? {
|
|
7023
|
+
available: false,
|
|
7024
|
+
installationId,
|
|
7025
|
+
ownerGeneration: 0,
|
|
7026
|
+
registrationGeneration: 0,
|
|
7027
|
+
status: "disabled",
|
|
7028
|
+
preferences: DEFAULT_PUSH_PREFERENCES
|
|
7029
|
+
} : await this.pushGateway.config(owner, installationId).catch((error) => {
|
|
7030
|
+
throw safePushFailure(error);
|
|
7031
|
+
}), this.tlsEnabled);
|
|
7032
|
+
return;
|
|
7033
|
+
}
|
|
7034
|
+
if (method !== "POST" || target.search !== "") throw new HttpError(method === "POST" ? 400 : 405, method === "POST" ? "bad_request" : "method_not_allowed");
|
|
7035
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
7036
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
7037
|
+
const service = this.pushGateway;
|
|
7038
|
+
if (service === void 0) throw new HttpError(503, "push_unavailable");
|
|
7039
|
+
try {
|
|
7040
|
+
if (target.decodedPathname === MOBILE_PUSH_SUBSCRIPTION_PATH) {
|
|
7041
|
+
const enrollment = parsePushEnrollment(body);
|
|
7042
|
+
sendJson(response, 200, await service.enroll(owner, enrollment), this.tlsEnabled);
|
|
7043
|
+
return;
|
|
7044
|
+
}
|
|
7045
|
+
if (target.decodedPathname === MOBILE_PUSH_PREFERENCES_PATH) {
|
|
7046
|
+
if (!exactKeys$1(body, [
|
|
7047
|
+
"installationId",
|
|
7048
|
+
"preferences",
|
|
7049
|
+
"ownerGeneration",
|
|
7050
|
+
"registrationGeneration"
|
|
7051
|
+
])) throw new HttpError(400, "bad_request");
|
|
7052
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7053
|
+
const preferences = parsePushPreferences(body.preferences);
|
|
7054
|
+
const guard = {
|
|
7055
|
+
ownerGeneration: parseGeneration(body.ownerGeneration, true),
|
|
7056
|
+
registrationGeneration: parseGeneration(body.registrationGeneration, true)
|
|
7057
|
+
};
|
|
7058
|
+
sendJson(response, 200, await service.preferences(owner, installationId, preferences, guard), this.tlsEnabled);
|
|
7059
|
+
return;
|
|
7060
|
+
}
|
|
7061
|
+
if (target.decodedPathname === MOBILE_PUSH_DISABLE_PATH) {
|
|
7062
|
+
if (!exactKeys$1(body, body.ifGeneration === void 0 ? ["installationId"] : ["installationId", "ifGeneration"])) throw new HttpError(400, "bad_request");
|
|
7063
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7064
|
+
const ifGeneration = body.ifGeneration === void 0 ? void 0 : parseGeneration(body.ifGeneration, false);
|
|
7065
|
+
await service.disable(owner, installationId, ifGeneration);
|
|
7066
|
+
sendJson(response, 200, { disabled: true }, this.tlsEnabled);
|
|
7067
|
+
return;
|
|
7068
|
+
}
|
|
7069
|
+
if (target.decodedPathname === MOBILE_PUSH_TEST_PATH) {
|
|
7070
|
+
if (!exactKeys$1(body, ["installationId"])) throw new HttpError(400, "bad_request");
|
|
7071
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7072
|
+
const key = JSON.stringify([
|
|
7073
|
+
owner.scopeId,
|
|
7074
|
+
owner.deviceId,
|
|
7075
|
+
owner.origin,
|
|
7076
|
+
installationId
|
|
7077
|
+
]);
|
|
7078
|
+
if (this.pushTestsInFlight.has(key)) throw new HttpError(429, "rate_limited");
|
|
7079
|
+
this.pushTestsInFlight.add(key);
|
|
7080
|
+
try {
|
|
7081
|
+
await service.test(owner, installationId);
|
|
7082
|
+
} finally {
|
|
7083
|
+
this.pushTestsInFlight.delete(key);
|
|
7084
|
+
}
|
|
7085
|
+
sendJson(response, 200, { accepted: true }, this.tlsEnabled);
|
|
7086
|
+
return;
|
|
7087
|
+
}
|
|
7088
|
+
if (target.decodedPathname === MOBILE_PUSH_RESOLVE_PATH) {
|
|
7089
|
+
if (!exactKeys$1(body, ["reference"]) || typeof body.reference !== "string" || body.reference.length === 0 || body.reference.length > 256 || !/^[A-Za-z0-9_-]+$/u.test(body.reference)) throw new HttpError(400, "bad_request");
|
|
7090
|
+
const resolved = await service.resolve(owner, body.reference);
|
|
7091
|
+
if (resolved === void 0) throw new HttpError(404, "not_found");
|
|
7092
|
+
sendJson(response, 200, resolved, this.tlsEnabled);
|
|
7093
|
+
return;
|
|
7094
|
+
}
|
|
7095
|
+
throw new HttpError(404, "not_found");
|
|
7096
|
+
} catch (error) {
|
|
7097
|
+
if (error instanceof HttpError) throw error;
|
|
7098
|
+
throw safePushFailure(error);
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
6699
7101
|
async handleLogout(request, response) {
|
|
6700
7102
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
6701
7103
|
const authorization = this.authorize(request);
|
|
6702
7104
|
this.requireCsrf(request, authorization);
|
|
7105
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
7106
|
+
if (this.pushGateway !== void 0) await this.pushGateway.logout(owner).catch((error) => {
|
|
7107
|
+
throw safePushFailure(error);
|
|
7108
|
+
});
|
|
6703
7109
|
this.access.logout(authorization);
|
|
6704
7110
|
response.setHeader("Set-Cookie", [
|
|
6705
7111
|
cookie(SESSION_COOKIE, "", {
|
|
@@ -6741,6 +7147,21 @@ var MobileAccessGateway = class {
|
|
|
6741
7147
|
assertExternalTrust(request, policy, isMutation);
|
|
6742
7148
|
if (target.decodedPathname === "/api/mobile-access" || target.decodedPathname.startsWith(`/api/mobile-access/`)) throw new HttpError(404, "not_found");
|
|
6743
7149
|
if (request.method === "TRACE" || request.method === "CONNECT") throw new HttpError(405, "method_not_allowed");
|
|
7150
|
+
if (target.search === "" && (request.method === "GET" || request.method === "HEAD")) {
|
|
7151
|
+
const asset = await mobilePushAsset(target.decodedPathname);
|
|
7152
|
+
if (asset !== void 0) {
|
|
7153
|
+
const body = Buffer.from(asset.body);
|
|
7154
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
7155
|
+
response.writeHead(200, {
|
|
7156
|
+
"Content-Type": asset.contentType,
|
|
7157
|
+
"Content-Length": body.byteLength,
|
|
7158
|
+
"Cache-Control": "public, max-age=3600"
|
|
7159
|
+
});
|
|
7160
|
+
if (request.method === "HEAD") response.end();
|
|
7161
|
+
else response.end(body);
|
|
7162
|
+
return;
|
|
7163
|
+
}
|
|
7164
|
+
}
|
|
6744
7165
|
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/health`) {
|
|
6745
7166
|
sendJson(response, 200, { ok: true }, this.tlsEnabled);
|
|
6746
7167
|
return;
|
|
@@ -6832,8 +7253,17 @@ var MobileAccessGateway = class {
|
|
|
6832
7253
|
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
6833
7254
|
const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
|
|
6834
7255
|
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
7256
|
+
const mobilePushRoute = [
|
|
7257
|
+
MOBILE_PUSH_CONFIG_PATH,
|
|
7258
|
+
MOBILE_PUSH_SUBSCRIPTION_PATH,
|
|
7259
|
+
MOBILE_PUSH_PREFERENCES_PATH,
|
|
7260
|
+
MOBILE_PUSH_DISABLE_PATH,
|
|
7261
|
+
MOBILE_PUSH_TEST_PATH,
|
|
7262
|
+
MOBILE_PUSH_RESOLVE_PATH
|
|
7263
|
+
].includes(target.decodedPathname);
|
|
6835
7264
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
6836
7265
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
7266
|
+
const requestedCompatRevision = mobileCompatibilityRevision(target.decodedPathname);
|
|
6837
7267
|
const customAsset = request.method === "GET" || request.method === "HEAD" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
6838
7268
|
file: this.config.customCssFile,
|
|
6839
7269
|
contentType: "text/css; charset=utf-8",
|
|
@@ -6850,8 +7280,13 @@ var MobileAccessGateway = class {
|
|
|
6850
7280
|
file: this.config.mobileCompatibilityFile,
|
|
6851
7281
|
contentType: "text/javascript; charset=utf-8",
|
|
6852
7282
|
fallback: void 0
|
|
6853
|
-
} : void 0
|
|
6854
|
-
|
|
7283
|
+
} : requestedCompatRevision === void 0 ? void 0 : {
|
|
7284
|
+
file: this.config.mobileCompatibilityFile,
|
|
7285
|
+
contentType: "text/javascript; charset=utf-8",
|
|
7286
|
+
fallback: void 0,
|
|
7287
|
+
digest: requestedCompatRevision
|
|
7288
|
+
} : void 0;
|
|
7289
|
+
if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && !mobilePushRoute && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
|
|
6855
7290
|
if (![
|
|
6856
7291
|
"GET",
|
|
6857
7292
|
"HEAD",
|
|
@@ -6888,6 +7323,10 @@ var MobileAccessGateway = class {
|
|
|
6888
7323
|
this.authFailures.record("csrf", mapError(error).status, request, target.decodedPathname, policy.canonicalOrigin(request.headers.origin), authorization);
|
|
6889
7324
|
throw error;
|
|
6890
7325
|
}
|
|
7326
|
+
if (mobilePushRoute) {
|
|
7327
|
+
await this.handleMobilePushRequest(target, request, response, authorization);
|
|
7328
|
+
return;
|
|
7329
|
+
}
|
|
6891
7330
|
if (clientModules) {
|
|
6892
7331
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
6893
7332
|
if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
@@ -6953,23 +7392,32 @@ var MobileAccessGateway = class {
|
|
|
6953
7392
|
}
|
|
6954
7393
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
6955
7394
|
operation.signal.throwIfAborted();
|
|
6956
|
-
const
|
|
7395
|
+
const compressible = isCompressibleContentType(customAsset.contentType) && body.byteLength >= MIN_COMPRESSIBLE_BYTES;
|
|
7396
|
+
const compressed = compressible && acceptsGzip(request.headers["accept-encoding"]);
|
|
7397
|
+
const digest = createHash("sha256").update(body).digest("hex");
|
|
7398
|
+
if (customAsset.digest !== void 0 && customAsset.digest !== digest) throw new HttpError(404, "not_found");
|
|
7399
|
+
const etag = compressed ? `"${digest}-gzip"` : `"${digest}"`;
|
|
6957
7400
|
const responseHeaders = { ETag: etag };
|
|
6958
7401
|
if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
|
|
7402
|
+
if (compressible) responseHeaders.Vary = "Accept-Encoding";
|
|
6959
7403
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
6960
|
-
response.setHeader("Cache-Control", "private, no-cache");
|
|
7404
|
+
response.setHeader("Cache-Control", customAsset.digest === void 0 ? "private, no-cache" : "private, max-age=31536000, immutable");
|
|
6961
7405
|
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
6962
7406
|
response.writeHead(304, responseHeaders);
|
|
6963
7407
|
response.end();
|
|
6964
7408
|
return;
|
|
6965
7409
|
}
|
|
7410
|
+
operation.signal.throwIfAborted();
|
|
7411
|
+
const payload = compressed ? await gzipBuffer(body) : body;
|
|
7412
|
+
operation.signal.throwIfAborted();
|
|
6966
7413
|
response.writeHead(200, {
|
|
6967
7414
|
...responseHeaders,
|
|
7415
|
+
...compressed ? { "Content-Encoding": "gzip" } : {},
|
|
6968
7416
|
"Content-Type": customAsset.contentType,
|
|
6969
|
-
"Content-Length":
|
|
7417
|
+
"Content-Length": payload.byteLength
|
|
6970
7418
|
});
|
|
6971
7419
|
if (request.method === "HEAD") response.end();
|
|
6972
|
-
else response.end(
|
|
7420
|
+
else response.end(payload);
|
|
6973
7421
|
return;
|
|
6974
7422
|
} finally {
|
|
6975
7423
|
operation.release();
|
|
@@ -7731,10 +8179,12 @@ var MobileAccessGateway = class {
|
|
|
7731
8179
|
const cookies = browserAuthCookies(request.headers.cookie);
|
|
7732
8180
|
const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
|
|
7733
8181
|
const viewId = randomBytes(16).toString("hex");
|
|
8182
|
+
const compatPath = await this.mobileCompatibilityUrl(operation.signal);
|
|
7734
8183
|
const rewritten = rewriteMobileIndexWithBatch(html, {
|
|
7735
8184
|
...options,
|
|
7736
8185
|
csrfCookie,
|
|
7737
|
-
viewId
|
|
8186
|
+
viewId,
|
|
8187
|
+
compatPath
|
|
7738
8188
|
});
|
|
7739
8189
|
operation.signal.throwIfAborted();
|
|
7740
8190
|
for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
|
|
@@ -7956,6 +8406,28 @@ var MobileAccessGateway = class {
|
|
|
7956
8406
|
await upstreamClosed;
|
|
7957
8407
|
}
|
|
7958
8408
|
}
|
|
8409
|
+
/**
|
|
8410
|
+
* Name the compatibility bundle by content, so a reload asks for it by revision.
|
|
8411
|
+
*
|
|
8412
|
+
* The digest is computed from the bytes on every page load rather than memoised on
|
|
8413
|
+
* a modification-time signature: an edit that keeps the length and restores the
|
|
8414
|
+
* timestamp (a build step preserving mtime, a same-size patch) is invisible to such
|
|
8415
|
+
* a signature, and the document would then name a revision the route refuses with
|
|
8416
|
+
* `404` — losing the shim exactly on the old WebViews it exists for. Hashing the
|
|
8417
|
+
* ~78 KB bundle costs far less than the page load it belongs to.
|
|
8418
|
+
*
|
|
8419
|
+
* A missing bundle keeps the plain path, where the route reports the usual `503`
|
|
8420
|
+
* instead of handing the document a URL that cannot exist.
|
|
8421
|
+
*/
|
|
8422
|
+
async mobileCompatibilityUrl(signal) {
|
|
8423
|
+
try {
|
|
8424
|
+
const body = await readFile(this.config.mobileCompatibilityFile, { signal });
|
|
8425
|
+
return mobileCompatibilityRevisionPath(createHash("sha256").update(body).digest("hex"));
|
|
8426
|
+
} catch (error) {
|
|
8427
|
+
if (signal.aborted) throw error;
|
|
8428
|
+
return MOBILE_COMPAT_PATH;
|
|
8429
|
+
}
|
|
8430
|
+
}
|
|
7959
8431
|
async serveMobileBootBatch(key, request, response, authorization) {
|
|
7960
8432
|
if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
|
|
7961
8433
|
const stored = this.mobileBootBatches.get(key);
|
|
@@ -8682,6 +9154,10 @@ var MobileAccessGateway = class {
|
|
|
8682
9154
|
}
|
|
8683
9155
|
async performClose() {
|
|
8684
9156
|
this.closing = true;
|
|
9157
|
+
const detachPushOrigin = this.detachPushOrigin;
|
|
9158
|
+
this.detachPushOrigin = void 0;
|
|
9159
|
+
detachPushOrigin?.();
|
|
9160
|
+
this.removePushDeviceListener();
|
|
8685
9161
|
const failures = [];
|
|
8686
9162
|
const directoryDrain = Promise.all(this.workspaceDirectoryTasks);
|
|
8687
9163
|
for (const controller of this.clientModuleOperations) controller.abort();
|
|
@@ -8733,6 +9209,7 @@ var MobileAccessGateway = class {
|
|
|
8733
9209
|
failures.push(error);
|
|
8734
9210
|
}
|
|
8735
9211
|
await accessClose;
|
|
9212
|
+
await Promise.allSettled(this.pushDeviceRevocations);
|
|
8736
9213
|
await Promise.allSettled(this.clientModuleTasks);
|
|
8737
9214
|
await Promise.allSettled(this.clientModuleRevocations);
|
|
8738
9215
|
await Promise.allSettled(this.mobileGraphTasks);
|
|
@@ -11391,6 +11868,11 @@ async function saveManagedTrustedNetworks(setupFile, values) {
|
|
|
11391
11868
|
await restrictPrivateFile(setupFile);
|
|
11392
11869
|
return next;
|
|
11393
11870
|
}
|
|
11871
|
+
/** Persist an existing managed setup without resetting its identity, paths or extra trusted networks. */
|
|
11872
|
+
async function saveManagedSetup(setupFile, setup) {
|
|
11873
|
+
const validated = parseManagedSetup(setup);
|
|
11874
|
+
await atomicWrite(setupFile, `${JSON.stringify(validated, null, 2)}\n`);
|
|
11875
|
+
}
|
|
11394
11876
|
function privateIpv4(value) {
|
|
11395
11877
|
const parts = value.split(".").map(Number);
|
|
11396
11878
|
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
@@ -17209,6 +17691,44 @@ async function uninstallVps(serverAddress, uninstall, input, options = {}) {
|
|
|
17209
17691
|
});
|
|
17210
17692
|
}
|
|
17211
17693
|
//#endregion
|
|
17694
|
+
//#region src/lan-rebind.ts
|
|
17695
|
+
/** An unreadable setup is rejected before stopping the listener or replacing any file. */
|
|
17696
|
+
var ManagedLanConfigurationError = class extends Error {
|
|
17697
|
+
constructor(cause) {
|
|
17698
|
+
super("managed LAN configuration is unreadable", { cause });
|
|
17699
|
+
this.name = "ManagedLanConfigurationError";
|
|
17700
|
+
}
|
|
17701
|
+
};
|
|
17702
|
+
/** Rebind an existing listener; callers serialize this call with all other setup-file writes. */
|
|
17703
|
+
async function rebindManagedLan(options) {
|
|
17704
|
+
let previous;
|
|
17705
|
+
try {
|
|
17706
|
+
previous = parseManagedSetup(JSON.parse(await readFile(options.setupFile, "utf8")));
|
|
17707
|
+
await readManagedCa(previous.tls);
|
|
17708
|
+
} catch (error) {
|
|
17709
|
+
throw new ManagedLanConfigurationError(error);
|
|
17710
|
+
}
|
|
17711
|
+
const result = await options.controller.reconfigure(async () => {
|
|
17712
|
+
const next = Object.freeze({
|
|
17713
|
+
...previous,
|
|
17714
|
+
networkInterface: options.network.name
|
|
17715
|
+
});
|
|
17716
|
+
await refreshManagedServerCertificate(next, options.network.address);
|
|
17717
|
+
await saveManagedSetup(options.setupFile, next);
|
|
17718
|
+
options.publish(next);
|
|
17719
|
+
return next;
|
|
17720
|
+
}, async () => {
|
|
17721
|
+
await saveManagedSetup(options.setupFile, previous);
|
|
17722
|
+
options.publish(previous);
|
|
17723
|
+
const network = options.selectPrevious?.(previous) ?? selectLanNetwork(void 0, previous.networkInterface);
|
|
17724
|
+
await refreshManagedServerCertificate(previous, network.address);
|
|
17725
|
+
});
|
|
17726
|
+
return {
|
|
17727
|
+
setup: result.value,
|
|
17728
|
+
running: result.running
|
|
17729
|
+
};
|
|
17730
|
+
}
|
|
17731
|
+
//#endregion
|
|
17212
17732
|
//#region src/funnel.ts
|
|
17213
17733
|
const MAX_PROTOCOL_LINE_BYTES = 16384;
|
|
17214
17734
|
const FUNNEL_START_TIMEOUT_MS = 45e3;
|
|
@@ -19771,80 +20291,2969 @@ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
|
|
|
19771
20291
|
return file;
|
|
19772
20292
|
}
|
|
19773
20293
|
//#endregion
|
|
19774
|
-
//#region src/
|
|
19775
|
-
const
|
|
19776
|
-
const
|
|
19777
|
-
|
|
19778
|
-
|
|
19779
|
-
|
|
19780
|
-
|
|
19781
|
-
|
|
19782
|
-
|
|
19783
|
-
|
|
19784
|
-
|
|
19785
|
-
|
|
19786
|
-
|
|
19787
|
-
|
|
19788
|
-
|
|
19789
|
-
|
|
19790
|
-
|
|
19791
|
-
|
|
19792
|
-
|
|
19793
|
-
|
|
19794
|
-
|
|
20294
|
+
//#region src/mobile-push/contracts.ts
|
|
20295
|
+
const OUTCOME_COALESCE_MS = 1e3;
|
|
20296
|
+
const ATTENTION_TTL_MS = 3e5;
|
|
20297
|
+
const OUTCOME_TTL_MS = 18e5;
|
|
20298
|
+
const ATTEMPT_TIMEOUT_MS = 1e4;
|
|
20299
|
+
//#endregion
|
|
20300
|
+
//#region src/mobile-push/requests.ts
|
|
20301
|
+
var RequestLifetimes = class {
|
|
20302
|
+
#records = /* @__PURE__ */ new Map();
|
|
20303
|
+
#byCall = /* @__PURE__ */ new Map();
|
|
20304
|
+
#clock;
|
|
20305
|
+
#emit;
|
|
20306
|
+
#ctx;
|
|
20307
|
+
#closed = false;
|
|
20308
|
+
constructor(ctx, emit, clock = Date.now) {
|
|
20309
|
+
this.#ctx = ctx;
|
|
20310
|
+
this.#emit = emit;
|
|
20311
|
+
this.#clock = clock;
|
|
20312
|
+
}
|
|
20313
|
+
observeQuestion(agent, request, next) {
|
|
20314
|
+
const question = Array.isArray(request?.questions) ? request.questions[0] : void 0;
|
|
20315
|
+
if (!question || !request?.signal?.aborted) {
|
|
20316
|
+
const intent = question?.intent;
|
|
20317
|
+
const category = intent?.kind === "plan-review" ? "plan" : "question";
|
|
20318
|
+
const callId = intent?.kind === "plan-review" && typeof intent.callId === "string" ? intent.callId : typeof request?.wait?.callId === "string" ? request.wait.callId : void 0;
|
|
20319
|
+
const sessionId = String(agent?.id ?? request?.agent?.id ?? "");
|
|
20320
|
+
const rec = sessionId ? this.#record(agent, sessionId, category, callId, request?.signal, ATTENTION_TTL_MS, request?.wait?.timed === true) : void 0;
|
|
20321
|
+
if (rec) this.#arm(rec);
|
|
20322
|
+
return Promise.resolve().then(next).then((result) => {
|
|
20323
|
+
if (rec && !(rec.timed && result && typeof result === "object" && result.pending === true)) {
|
|
20324
|
+
rec.settled = true;
|
|
20325
|
+
rec.live = false;
|
|
20326
|
+
}
|
|
20327
|
+
return result;
|
|
20328
|
+
}, (error) => {
|
|
20329
|
+
if (rec && !(rec.timed && ["NO_PROVIDER", "ASK_TIMED_OUT"].includes(error?.code))) {
|
|
20330
|
+
rec.settled = true;
|
|
20331
|
+
rec.live = false;
|
|
20332
|
+
}
|
|
20333
|
+
throw error;
|
|
20334
|
+
});
|
|
20335
|
+
}
|
|
20336
|
+
return Promise.resolve().then(next);
|
|
19795
20337
|
}
|
|
19796
|
-
|
|
19797
|
-
|
|
19798
|
-
|
|
19799
|
-
|
|
19800
|
-
|
|
19801
|
-
|
|
19802
|
-
|
|
19803
|
-
|
|
20338
|
+
observeApproval(agent, request, next) {
|
|
20339
|
+
const sessionId = String(agent?.id ?? request?.agent?.id ?? "");
|
|
20340
|
+
const rec = !request?.signal?.aborted && sessionId ? this.#record(agent, sessionId, "approval", request?.callId, request?.signal, ATTENTION_TTL_MS) : void 0;
|
|
20341
|
+
if (rec) this.#arm(rec);
|
|
20342
|
+
return Promise.resolve().then(next).finally(() => {
|
|
20343
|
+
if (rec) {
|
|
20344
|
+
rec.settled = true;
|
|
20345
|
+
rec.live = false;
|
|
20346
|
+
}
|
|
20347
|
+
});
|
|
20348
|
+
}
|
|
20349
|
+
queuedReply(sessionId, callId) {
|
|
20350
|
+
const rec = this.#byCall.get(`${sessionId}\u0000${callId}`);
|
|
20351
|
+
if (rec) this.#invalidate(rec, true);
|
|
20352
|
+
}
|
|
20353
|
+
invalidateSession(sessionId, options = {}) {
|
|
20354
|
+
for (const rec of this.#records.values()) if (rec.source.sessionId === sessionId && !(options.preserveTimed && rec.timed)) this.#invalidate(rec, false);
|
|
20355
|
+
}
|
|
20356
|
+
reconcileLive(source) {
|
|
20357
|
+
if (source.expiresAt <= this.#clock()) return "stale";
|
|
20358
|
+
const rec = this.#records.get(source.sourceId);
|
|
20359
|
+
if (!rec) return "unverifiable";
|
|
20360
|
+
if (this.#closed || rec.settled || rec.queuedReply || !rec.live) return "stale";
|
|
20361
|
+
const timed = this.#callForSource(source);
|
|
20362
|
+
if (rec.timed && !timed) return "unverifiable";
|
|
20363
|
+
if (!timed) return "eligible";
|
|
20364
|
+
const state = this.#projection(source.sessionId);
|
|
20365
|
+
const view = state?.questions ?? state;
|
|
20366
|
+
if ((Array.isArray(view?.active) ? view.active : []).some((item) => item.callId === timed && (item.state === "open" || item.state === "continued"))) return "eligible";
|
|
20367
|
+
if (rec.timed) return "stale";
|
|
20368
|
+
if ((Array.isArray(view?.settled) ? view.settled : []).some((item) => item.callId === timed)) return "stale";
|
|
20369
|
+
return "eligible";
|
|
20370
|
+
}
|
|
20371
|
+
eligible(source) {
|
|
20372
|
+
return this.reconcileLive(source) === "eligible";
|
|
20373
|
+
}
|
|
20374
|
+
/** Retires expired records at their original TTL; returns how many were removed. */
|
|
20375
|
+
sweep(now = this.#clock()) {
|
|
20376
|
+
let removed = 0;
|
|
20377
|
+
for (const [sourceId, rec] of this.#records) {
|
|
20378
|
+
if (rec.source.expiresAt > now) continue;
|
|
20379
|
+
this.#retire(rec);
|
|
20380
|
+
this.#records.delete(sourceId);
|
|
20381
|
+
removed += 1;
|
|
20382
|
+
}
|
|
20383
|
+
return removed;
|
|
20384
|
+
}
|
|
20385
|
+
close() {
|
|
20386
|
+
this.#closed = true;
|
|
20387
|
+
for (const rec of this.#records.values()) this.#retire(rec);
|
|
20388
|
+
this.#records.clear();
|
|
20389
|
+
this.#byCall.clear();
|
|
20390
|
+
}
|
|
20391
|
+
#projection(sessionId) {
|
|
20392
|
+
const session = this.#ctx.agents?.get?.(sessionId)?.session ?? { id: sessionId };
|
|
20393
|
+
return this.#ctx.sessionProjections?.stateOf?.(session, "userQuestions");
|
|
20394
|
+
}
|
|
20395
|
+
#retire(rec) {
|
|
20396
|
+
if (rec.timer) {
|
|
20397
|
+
clearTimeout(rec.timer);
|
|
20398
|
+
rec.timer = void 0;
|
|
20399
|
+
}
|
|
20400
|
+
rec.disposeAbort?.();
|
|
20401
|
+
rec.disposeAbort = void 0;
|
|
20402
|
+
if (typeof rec.source.callId === "string") {
|
|
20403
|
+
const key = `${rec.source.sessionId}\u0000${rec.source.callId}`;
|
|
20404
|
+
if (this.#byCall.get(key) === rec) this.#byCall.delete(key);
|
|
20405
|
+
}
|
|
20406
|
+
}
|
|
20407
|
+
#invalidate(rec, queuedReply) {
|
|
20408
|
+
rec.settled = true;
|
|
20409
|
+
rec.live = false;
|
|
20410
|
+
if (queuedReply) rec.queuedReply = true;
|
|
20411
|
+
if (rec.timer) clearTimeout(rec.timer);
|
|
20412
|
+
rec.timer = void 0;
|
|
20413
|
+
}
|
|
20414
|
+
#callForSource(source) {
|
|
20415
|
+
return source.callId;
|
|
20416
|
+
}
|
|
20417
|
+
#record(agent, sessionId, category, callId, signal, ttl, timed = false) {
|
|
20418
|
+
const createdAt = this.#clock();
|
|
20419
|
+
const sourceId = `request:${sessionId}:${category}:${typeof callId === "string" && callId ? callId : `dispatch-${crypto.randomUUID()}`}`;
|
|
20420
|
+
const existing = this.#records.get(sourceId);
|
|
20421
|
+
if (existing && existing.source.expiresAt > createdAt) return existing;
|
|
20422
|
+
if (existing) this.#retire(existing);
|
|
20423
|
+
let turn;
|
|
19804
20424
|
try {
|
|
19805
|
-
|
|
19806
|
-
|
|
19807
|
-
|
|
19808
|
-
|
|
19809
|
-
|
|
20425
|
+
const boundary = this.#ctx.sessionProjections?.stateOf?.(agent.session ?? agent, "turnBoundary");
|
|
20426
|
+
if (boundary?.openTurnStartSeq != null && Number.isSafeInteger(boundary?.lastTurn)) turn = boundary.lastTurn;
|
|
20427
|
+
} catch {}
|
|
20428
|
+
const rec = {
|
|
20429
|
+
source: {
|
|
20430
|
+
sourceId,
|
|
20431
|
+
sessionId,
|
|
20432
|
+
targetSessionId: this.#root(agent).id,
|
|
20433
|
+
category,
|
|
20434
|
+
createdAt,
|
|
20435
|
+
expiresAt: createdAt + ttl,
|
|
20436
|
+
lifetime: "live",
|
|
20437
|
+
...turn === void 0 ? {} : { turn },
|
|
20438
|
+
...typeof callId === "string" ? { callId } : {}
|
|
20439
|
+
},
|
|
20440
|
+
settled: false,
|
|
20441
|
+
queuedReply: false,
|
|
20442
|
+
timed,
|
|
20443
|
+
live: true
|
|
20444
|
+
};
|
|
20445
|
+
this.#records.set(sourceId, rec);
|
|
20446
|
+
if (typeof callId === "string") this.#byCall.set(`${sessionId}\u0000${callId}`, rec);
|
|
20447
|
+
const onAbort = () => {
|
|
20448
|
+
if (rec.timed && (signal?.reason)?.code === "ASK_TIMED_OUT") return;
|
|
20449
|
+
rec.settled = true;
|
|
20450
|
+
rec.live = false;
|
|
20451
|
+
};
|
|
20452
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
20453
|
+
rec.disposeAbort = () => {
|
|
19810
20454
|
try {
|
|
19811
|
-
|
|
19812
|
-
} catch
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
|
|
20455
|
+
signal?.removeEventListener("abort", onAbort);
|
|
20456
|
+
} catch {}
|
|
20457
|
+
};
|
|
20458
|
+
return rec;
|
|
20459
|
+
}
|
|
20460
|
+
#root(agent) {
|
|
20461
|
+
const registry = this.#ctx.agents;
|
|
20462
|
+
if (!registry?.isOwnedBy || !registry.get || !registry.list) return agent;
|
|
20463
|
+
let current = agent;
|
|
20464
|
+
for (let count = 0; count < 64; count++) {
|
|
20465
|
+
const parent = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
|
|
20466
|
+
if (!parent) break;
|
|
20467
|
+
current = parent;
|
|
20468
|
+
}
|
|
20469
|
+
return current;
|
|
20470
|
+
}
|
|
20471
|
+
#arm(rec) {
|
|
20472
|
+
if (rec.timer) return;
|
|
20473
|
+
rec.timer = setTimeout(() => {
|
|
20474
|
+
rec.timer = void 0;
|
|
20475
|
+
if (this.#closed || rec.settled || rec.queuedReply) return;
|
|
20476
|
+
this.#emit(rec.source);
|
|
20477
|
+
}, 250);
|
|
20478
|
+
}
|
|
20479
|
+
};
|
|
20480
|
+
//#endregion
|
|
20481
|
+
//#region src/mobile-push/outcomes.ts
|
|
20482
|
+
const isCompletedGoalPhase = (phase) => phase === "complete";
|
|
20483
|
+
var OutcomeLifetimes = class {
|
|
20484
|
+
#ctx;
|
|
20485
|
+
#emit;
|
|
20486
|
+
#pending = /* @__PURE__ */ new Map();
|
|
20487
|
+
#goals = /* @__PURE__ */ new Map();
|
|
20488
|
+
#blockedTurns = /* @__PURE__ */ new Map();
|
|
20489
|
+
#sent = /* @__PURE__ */ new Map();
|
|
20490
|
+
#closed = false;
|
|
20491
|
+
constructor(ctx, emit) {
|
|
20492
|
+
this.#ctx = ctx;
|
|
20493
|
+
this.#emit = emit;
|
|
20494
|
+
}
|
|
20495
|
+
onGoal(payload) {
|
|
20496
|
+
const agent = payload?.agent;
|
|
20497
|
+
const change = payload?.change;
|
|
20498
|
+
const goal = change?.goal;
|
|
20499
|
+
const op = change?.operation;
|
|
20500
|
+
if (!agent?.id || !change) return;
|
|
20501
|
+
this.#cancel(agent.id);
|
|
20502
|
+
if (op === "clear" || !goal) {
|
|
20503
|
+
this.#goals.delete(agent.id);
|
|
20504
|
+
return;
|
|
19816
20505
|
}
|
|
19817
|
-
|
|
19818
|
-
|
|
19819
|
-
|
|
19820
|
-
|
|
19821
|
-
|
|
19822
|
-
|
|
20506
|
+
const id = String(goal.id ?? change?.ref?.id ?? "");
|
|
20507
|
+
const revision = Number(goal.revision ?? change?.ref?.revision);
|
|
20508
|
+
if (!id || !Number.isFinite(revision)) return;
|
|
20509
|
+
const record = {
|
|
20510
|
+
id,
|
|
20511
|
+
revision,
|
|
20512
|
+
phase: String(goal.phase ?? op ?? "")
|
|
20513
|
+
};
|
|
20514
|
+
this.#goals.set(agent.id, record);
|
|
20515
|
+
if (op === "block") {
|
|
20516
|
+
const boundary = this.#ctx.sessionProjections?.stateOf?.(agent.session ?? agent, "turnBoundary");
|
|
20517
|
+
const turn = boundary?.openTurnStartSeq != null && Number.isSafeInteger(boundary?.lastTurn) ? boundary.lastTurn : void 0;
|
|
20518
|
+
if (turn !== void 0) this.#blockedTurns.set(agent.id, {
|
|
20519
|
+
id,
|
|
20520
|
+
revision,
|
|
20521
|
+
turn
|
|
20522
|
+
});
|
|
20523
|
+
this.#schedule(agent, "goal", turn, {
|
|
20524
|
+
id,
|
|
20525
|
+
revision
|
|
20526
|
+
});
|
|
19823
20527
|
}
|
|
19824
|
-
|
|
19825
|
-
|
|
19826
|
-
|
|
19827
|
-
|
|
19828
|
-
|
|
19829
|
-
|
|
19830
|
-
|
|
19831
|
-
|
|
19832
|
-
|
|
19833
|
-
if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
|
|
19834
|
-
throw cleanupError;
|
|
20528
|
+
}
|
|
20529
|
+
onSessionEvent(session, event) {
|
|
20530
|
+
const type = event?.type;
|
|
20531
|
+
const data = event?.data ?? event;
|
|
20532
|
+
if (!session?.id || !data) return;
|
|
20533
|
+
const id = session.id;
|
|
20534
|
+
if (type === "agent/inbox/inserted" || type === "agent/inbox/spliced" || type === "turn/start") {
|
|
20535
|
+
this.#cancel(id);
|
|
20536
|
+
return;
|
|
19835
20537
|
}
|
|
20538
|
+
if (type !== "turn/end") return;
|
|
20539
|
+
const reason = data.reason?.kind;
|
|
20540
|
+
const closesBlockedTurn = this.#blockedTurns.get(id)?.turn === data.turn;
|
|
20541
|
+
if (closesBlockedTurn) this.#blockedTurns.delete(id);
|
|
20542
|
+
if (closesBlockedTurn && [
|
|
20543
|
+
"completed",
|
|
20544
|
+
"blocked",
|
|
20545
|
+
"error"
|
|
20546
|
+
].includes(reason)) return;
|
|
20547
|
+
if (reason === "completed") {
|
|
20548
|
+
const observedGoal = this.#goals.get(id);
|
|
20549
|
+
const activeGoal = observedGoal?.phase === "active" ? observedGoal : this.#projectedActiveGoal(session);
|
|
20550
|
+
if (observedGoal && isCompletedGoalPhase(observedGoal.phase)) {
|
|
20551
|
+
this.#schedule(session, "goal", data.turn, {
|
|
20552
|
+
id: observedGoal.id,
|
|
20553
|
+
revision: observedGoal.revision
|
|
20554
|
+
});
|
|
20555
|
+
this.#goals.delete(id);
|
|
20556
|
+
} else if (!activeGoal) this.#schedule(session, "response", data.turn);
|
|
20557
|
+
} else if (reason === "blocked" || reason === "error") this.#schedule(session, "failure", data.turn);
|
|
19836
20558
|
}
|
|
19837
|
-
|
|
20559
|
+
#projectedActiveGoal(session) {
|
|
20560
|
+
const goal = (this.#ctx.sessionProjections?.stateOf?.(session.session ?? session, "goal"))?.current?.goal;
|
|
20561
|
+
if (!goal || typeof goal.id !== "string" || !Number.isSafeInteger(goal.revision) || goal.phase !== "active") return void 0;
|
|
20562
|
+
return {
|
|
20563
|
+
id: goal.id,
|
|
20564
|
+
revision: goal.revision,
|
|
20565
|
+
phase: "active"
|
|
20566
|
+
};
|
|
20567
|
+
}
|
|
20568
|
+
onAgentError(_payload) {}
|
|
20569
|
+
reconcileLive(source) {
|
|
20570
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20571
|
+
if (this.#closed) return "stale";
|
|
20572
|
+
const candidate = this.#pending.get(source.sourceId);
|
|
20573
|
+
if (!candidate) return "unverifiable";
|
|
20574
|
+
return candidate.active ? "eligible" : "stale";
|
|
20575
|
+
}
|
|
20576
|
+
eligible(source) {
|
|
20577
|
+
return this.reconcileLive(source) === "eligible";
|
|
20578
|
+
}
|
|
20579
|
+
/** Retires expired candidates and sent-dedupe records at their original TTL; returns how many were removed. */
|
|
20580
|
+
sweep(now = Date.now()) {
|
|
20581
|
+
let removed = 0;
|
|
20582
|
+
for (const [id, candidate] of this.#pending) {
|
|
20583
|
+
if (candidate.source.expiresAt > now) continue;
|
|
20584
|
+
if (candidate.timer) {
|
|
20585
|
+
clearTimeout(candidate.timer);
|
|
20586
|
+
candidate.timer = void 0;
|
|
20587
|
+
}
|
|
20588
|
+
this.#pending.delete(id);
|
|
20589
|
+
removed += 1;
|
|
20590
|
+
}
|
|
20591
|
+
for (const [id, expiresAt] of this.#sent) {
|
|
20592
|
+
if (expiresAt > now) continue;
|
|
20593
|
+
this.#sent.delete(id);
|
|
20594
|
+
removed += 1;
|
|
20595
|
+
}
|
|
20596
|
+
return removed;
|
|
20597
|
+
}
|
|
20598
|
+
close() {
|
|
20599
|
+
this.#closed = true;
|
|
20600
|
+
for (const item of this.#pending.values()) if (item.timer) clearTimeout(item.timer);
|
|
20601
|
+
this.#pending.clear();
|
|
20602
|
+
this.#blockedTurns.clear();
|
|
20603
|
+
this.#goals.clear();
|
|
20604
|
+
this.#sent.clear();
|
|
20605
|
+
}
|
|
20606
|
+
#schedule(agent, category, turn, goal) {
|
|
20607
|
+
if (this.#closed || this.#isRuntimeChild(agent)) return;
|
|
20608
|
+
const createdAt = Date.now();
|
|
20609
|
+
const goalKey = goal ? `${goal.id}:${goal.revision}` : "";
|
|
20610
|
+
const sourceId = `${category}:${agent.id}:${turn ?? "goal"}:${goalKey}`;
|
|
20611
|
+
if (this.#sent.has(sourceId) || this.#pending.has(sourceId)) return;
|
|
20612
|
+
const source = {
|
|
20613
|
+
sourceId,
|
|
20614
|
+
sessionId: agent.id,
|
|
20615
|
+
targetSessionId: this.#root(agent).id,
|
|
20616
|
+
category,
|
|
20617
|
+
createdAt,
|
|
20618
|
+
expiresAt: createdAt + OUTCOME_TTL_MS,
|
|
20619
|
+
lifetime: "committed",
|
|
20620
|
+
...turn === void 0 ? {} : { turn },
|
|
20621
|
+
...goal === void 0 ? {} : { goal }
|
|
20622
|
+
};
|
|
20623
|
+
const candidate = {
|
|
20624
|
+
source,
|
|
20625
|
+
active: true
|
|
20626
|
+
};
|
|
20627
|
+
candidate.timer = setTimeout(() => {
|
|
20628
|
+
candidate.timer = void 0;
|
|
20629
|
+
if (this.#closed || !candidate.active) return;
|
|
20630
|
+
this.#sent.set(sourceId, source.expiresAt);
|
|
20631
|
+
this.#emit(source);
|
|
20632
|
+
}, OUTCOME_COALESCE_MS);
|
|
20633
|
+
this.#pending.set(sourceId, candidate);
|
|
20634
|
+
}
|
|
20635
|
+
#cancel(sessionId) {
|
|
20636
|
+
this.#blockedTurns.delete(sessionId);
|
|
20637
|
+
for (const [id, candidate] of this.#pending) {
|
|
20638
|
+
if (candidate.source.sessionId !== sessionId) continue;
|
|
20639
|
+
clearTimeout(candidate.timer);
|
|
20640
|
+
candidate.active = false;
|
|
20641
|
+
this.#pending.delete(id);
|
|
20642
|
+
}
|
|
20643
|
+
}
|
|
20644
|
+
#isRuntimeChild(agent) {
|
|
20645
|
+
const registry = this.#ctx.agents;
|
|
20646
|
+
if (!registry?.isOwnedBy || !registry.list) return false;
|
|
20647
|
+
return registry.list().some((owner) => registry.isOwnedBy?.(agent.id, owner));
|
|
20648
|
+
}
|
|
20649
|
+
#root(agent) {
|
|
20650
|
+
const registry = this.#ctx.agents;
|
|
20651
|
+
if (!registry?.isOwnedBy || !registry.list) return agent;
|
|
20652
|
+
let current = agent;
|
|
20653
|
+
for (let count = 0; count < 64; count++) {
|
|
20654
|
+
const owner = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
|
|
20655
|
+
if (!owner) return current;
|
|
20656
|
+
current = owner;
|
|
20657
|
+
}
|
|
20658
|
+
return current;
|
|
20659
|
+
}
|
|
20660
|
+
};
|
|
19838
20661
|
//#endregion
|
|
19839
|
-
//#region src/
|
|
19840
|
-
|
|
19841
|
-
|
|
19842
|
-
|
|
19843
|
-
|
|
19844
|
-
|
|
19845
|
-
|
|
19846
|
-
|
|
19847
|
-
|
|
20662
|
+
//#region src/mobile-push/reconcile.ts
|
|
20663
|
+
const TURN_REASONS = [
|
|
20664
|
+
"completed",
|
|
20665
|
+
"error",
|
|
20666
|
+
"blocked",
|
|
20667
|
+
"aborted",
|
|
20668
|
+
"max-tokens",
|
|
20669
|
+
"interrupted",
|
|
20670
|
+
"forked",
|
|
20671
|
+
"other"
|
|
20672
|
+
];
|
|
20673
|
+
const GOAL_OPERATIONS = [
|
|
20674
|
+
"create",
|
|
20675
|
+
"edit",
|
|
20676
|
+
"pause",
|
|
20677
|
+
"resume",
|
|
20678
|
+
"complete",
|
|
20679
|
+
"block",
|
|
20680
|
+
"clear"
|
|
20681
|
+
];
|
|
20682
|
+
const GOAL_PHASES = [
|
|
20683
|
+
"active",
|
|
20684
|
+
"paused",
|
|
20685
|
+
"blocked",
|
|
20686
|
+
"complete",
|
|
20687
|
+
"cleared"
|
|
20688
|
+
];
|
|
20689
|
+
const turnEndSchema = z$1.object({
|
|
20690
|
+
turn: z$1.number().int().nonnegative(),
|
|
20691
|
+
reason: z$1.enum(TURN_REASONS)
|
|
20692
|
+
}).strict();
|
|
20693
|
+
const goalChangeSchema = z$1.object({
|
|
20694
|
+
id: z$1.string().min(1),
|
|
20695
|
+
revision: z$1.number().int().nonnegative(),
|
|
20696
|
+
phase: z$1.enum(GOAL_PHASES),
|
|
20697
|
+
operation: z$1.enum(GOAL_OPERATIONS),
|
|
20698
|
+
turn: z$1.number().int().nonnegative(),
|
|
20699
|
+
openTurn: z$1.boolean()
|
|
20700
|
+
}).strict();
|
|
20701
|
+
const mobilePushLifecycleSchema = z$1.object({
|
|
20702
|
+
latestTurn: z$1.number().int().nonnegative(),
|
|
20703
|
+
openTurn: z$1.number().int().nonnegative().nullable(),
|
|
20704
|
+
lastTurnEnd: turnEndSchema.nullable(),
|
|
20705
|
+
latestGoalChange: goalChangeSchema.nullable()
|
|
20706
|
+
}).strict();
|
|
20707
|
+
const MOBILE_PUSH_LIFECYCLE_KEY = "mobilePushLifecycle";
|
|
20708
|
+
const INITIAL_STATE = {
|
|
20709
|
+
latestTurn: 0,
|
|
20710
|
+
openTurn: null,
|
|
20711
|
+
lastTurnEnd: null,
|
|
20712
|
+
latestGoalChange: null
|
|
20713
|
+
};
|
|
20714
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20715
|
+
const safeNumber = (value) => Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
20716
|
+
/** Wire-visible verification metadata only; it stores no message text or diagnostics. */
|
|
20717
|
+
const mobilePushLifecycleProjectionDefinition = {
|
|
20718
|
+
key: MOBILE_PUSH_LIFECYCLE_KEY,
|
|
20719
|
+
stateSchema: mobilePushLifecycleSchema,
|
|
20720
|
+
init: (_header, _inheritedEventCount) => INITIAL_STATE,
|
|
20721
|
+
apply(state, event) {
|
|
20722
|
+
if (!isRecord(event)) return state;
|
|
20723
|
+
const data = isRecord(event.data) ? event.data : void 0;
|
|
20724
|
+
if (!data) return state;
|
|
20725
|
+
if (event.type === "turn/start") {
|
|
20726
|
+
const turn = safeNumber(data.turn);
|
|
20727
|
+
if (turn === void 0) return state;
|
|
20728
|
+
return {
|
|
20729
|
+
...state,
|
|
20730
|
+
latestTurn: turn,
|
|
20731
|
+
openTurn: turn,
|
|
20732
|
+
lastTurnEnd: null
|
|
20733
|
+
};
|
|
20734
|
+
}
|
|
20735
|
+
if (event.type === "turn/end") {
|
|
20736
|
+
const turn = safeNumber(data.turn);
|
|
20737
|
+
const rawReason = (isRecord(data.reason) ? data.reason : void 0)?.kind;
|
|
20738
|
+
if (turn === void 0 || typeof rawReason !== "string") return state;
|
|
20739
|
+
const reason = TURN_REASONS.includes(rawReason) ? rawReason : "other";
|
|
20740
|
+
return {
|
|
20741
|
+
...state,
|
|
20742
|
+
latestTurn: turn,
|
|
20743
|
+
openTurn: null,
|
|
20744
|
+
lastTurnEnd: {
|
|
20745
|
+
turn,
|
|
20746
|
+
reason
|
|
20747
|
+
}
|
|
20748
|
+
};
|
|
20749
|
+
}
|
|
20750
|
+
if (event.type !== "goal/change") return state;
|
|
20751
|
+
const rawOperation = data.operation;
|
|
20752
|
+
if (typeof rawOperation !== "string" || !GOAL_OPERATIONS.includes(rawOperation)) return state;
|
|
20753
|
+
const operation = rawOperation;
|
|
20754
|
+
const goal = isRecord(data.goal) ? data.goal : void 0;
|
|
20755
|
+
const cleared = isRecord(data.cleared) ? data.cleared : void 0;
|
|
20756
|
+
const goalId = goal?.id;
|
|
20757
|
+
const clearedId = cleared?.id;
|
|
20758
|
+
const id = typeof goalId === "string" ? goalId : typeof clearedId === "string" ? clearedId : void 0;
|
|
20759
|
+
const revision = safeNumber(goal?.revision ?? cleared?.revision);
|
|
20760
|
+
const phaseValue = operation === "clear" ? "cleared" : goal?.phase;
|
|
20761
|
+
if (!id || revision === void 0 || typeof phaseValue !== "string" || !GOAL_PHASES.includes(phaseValue)) return state;
|
|
20762
|
+
const phase = phaseValue;
|
|
20763
|
+
return {
|
|
20764
|
+
...state,
|
|
20765
|
+
latestGoalChange: {
|
|
20766
|
+
id,
|
|
20767
|
+
revision,
|
|
20768
|
+
phase,
|
|
20769
|
+
operation,
|
|
20770
|
+
turn: state.latestTurn,
|
|
20771
|
+
openTurn: state.openTurn !== null
|
|
20772
|
+
}
|
|
20773
|
+
};
|
|
20774
|
+
},
|
|
20775
|
+
wire: {
|
|
20776
|
+
viewSchema: mobilePushLifecycleSchema,
|
|
20777
|
+
view: (state) => state
|
|
20778
|
+
},
|
|
20779
|
+
stateVersion: 1
|
|
20780
|
+
};
|
|
20781
|
+
function projectionValues(ctx, agent) {
|
|
20782
|
+
try {
|
|
20783
|
+
return ctx.sessionProjections?.snapshot?.(agent.session ?? agent)?.values;
|
|
20784
|
+
} catch {
|
|
20785
|
+
return;
|
|
20786
|
+
}
|
|
20787
|
+
}
|
|
20788
|
+
function queueArrays(values, agent) {
|
|
20789
|
+
const queues = [];
|
|
20790
|
+
const inbox = isRecord(values.inbox) ? values.inbox : void 0;
|
|
20791
|
+
const projected = [inbox?.["next-turn"], inbox?.["next-step"]];
|
|
20792
|
+
if (projected.every(Array.isArray)) queues.push(...projected);
|
|
20793
|
+
const live = [agent?.inbox?.nextTurn, agent?.inbox?.nextStep];
|
|
20794
|
+
if (live.every(Array.isArray)) queues.push(...live);
|
|
20795
|
+
return queues.length > 0 ? queues : void 0;
|
|
20796
|
+
}
|
|
20797
|
+
function inboxContains(values, predicate, agent) {
|
|
20798
|
+
const queues = queueArrays(values, agent);
|
|
20799
|
+
if (!queues) return void 0;
|
|
20800
|
+
return queues.some((queue) => queue.some(predicate));
|
|
20801
|
+
}
|
|
20802
|
+
function lifecycle(values) {
|
|
20803
|
+
const parsed = mobilePushLifecycleSchema.safeParse(values[MOBILE_PUSH_LIFECYCLE_KEY]);
|
|
20804
|
+
return parsed.success ? parsed.data : void 0;
|
|
20805
|
+
}
|
|
20806
|
+
function verifyQuestion(source, values, agent) {
|
|
20807
|
+
const callId = source.callId;
|
|
20808
|
+
if (typeof callId !== "string" || !callId) return "unverifiable";
|
|
20809
|
+
const replyQueued = inboxContains(values, (message) => {
|
|
20810
|
+
const source = isRecord(message) && isRecord(message.source) ? message.source : void 0;
|
|
20811
|
+
return source?.kind === "user-question-reply" && source.callId === callId && source.outcome === "answered";
|
|
20812
|
+
}, agent);
|
|
20813
|
+
if (replyQueued === void 0) return "unverifiable";
|
|
20814
|
+
if (replyQueued) return "stale";
|
|
20815
|
+
const view = isRecord(values.userQuestions) ? values.userQuestions : void 0;
|
|
20816
|
+
const activeItems = Array.isArray(view?.active) ? view.active : void 0;
|
|
20817
|
+
const settledItems = Array.isArray(view?.settled) ? view.settled : void 0;
|
|
20818
|
+
if (!activeItems || !settledItems) return "unverifiable";
|
|
20819
|
+
const active = activeItems.find((item) => isRecord(item) && item.callId === callId);
|
|
20820
|
+
if (active) {
|
|
20821
|
+
if (active.state !== "open" && active.state !== "continued") return "stale";
|
|
20822
|
+
const state = lifecycle(values);
|
|
20823
|
+
const turn = safeNumber(source.turn);
|
|
20824
|
+
if (turn === void 0 || !state) return "unverifiable";
|
|
20825
|
+
if (state.latestTurn < turn) return "unverifiable";
|
|
20826
|
+
return "eligible";
|
|
20827
|
+
}
|
|
20828
|
+
return settledItems.some((item) => isRecord(item) && item.callId === callId) ? "stale" : "unverifiable";
|
|
20829
|
+
}
|
|
20830
|
+
function verifyOutcome(source, values, agent) {
|
|
20831
|
+
const turn = safeNumber(source.turn);
|
|
20832
|
+
if (turn === void 0) return "unverifiable";
|
|
20833
|
+
const queuedInput = inboxContains(values, (message) => message !== void 0 && message !== null, agent);
|
|
20834
|
+
if (queuedInput === void 0) return "unverifiable";
|
|
20835
|
+
if (queuedInput) return "stale";
|
|
20836
|
+
const state = lifecycle(values);
|
|
20837
|
+
if (!state) return "unverifiable";
|
|
20838
|
+
if (state.latestTurn > turn) return "stale";
|
|
20839
|
+
if (state.latestTurn < turn) return "unverifiable";
|
|
20840
|
+
const ended = state.lastTurnEnd;
|
|
20841
|
+
if (!ended || ended.turn !== turn) return "unverifiable";
|
|
20842
|
+
const cause = state.latestGoalChange;
|
|
20843
|
+
const goalEnvelope = isRecord(values.goal) ? values.goal : void 0;
|
|
20844
|
+
const currentGoal = isRecord(goalEnvelope?.goal) ? goalEnvelope.goal : void 0;
|
|
20845
|
+
if (source.category === "response") {
|
|
20846
|
+
if (ended.reason !== "completed") return "stale";
|
|
20847
|
+
if (cause?.turn === turn && (cause.operation === "complete" || cause.operation === "block")) return "stale";
|
|
20848
|
+
if (currentGoal?.phase === "active" && cause?.turn === turn) return "stale";
|
|
20849
|
+
return "eligible";
|
|
20850
|
+
}
|
|
20851
|
+
if (source.category === "failure") {
|
|
20852
|
+
if (ended.reason !== "error" && ended.reason !== "blocked") return "stale";
|
|
20853
|
+
if (cause?.turn === turn && cause.operation === "block") return "stale";
|
|
20854
|
+
return "eligible";
|
|
20855
|
+
}
|
|
20856
|
+
return "unverifiable";
|
|
20857
|
+
}
|
|
20858
|
+
function verifyGoal(source, values) {
|
|
20859
|
+
const identity = source.goal;
|
|
20860
|
+
if (!identity) return "unverifiable";
|
|
20861
|
+
const goalEnvelope = isRecord(values.goal) ? values.goal : void 0;
|
|
20862
|
+
const goalView = isRecord(goalEnvelope?.goal) ? goalEnvelope.goal : void 0;
|
|
20863
|
+
if (!goalView) return values.goal === null ? "stale" : "unverifiable";
|
|
20864
|
+
if (goalView.id !== identity.id || goalView.revision !== identity.revision) return "stale";
|
|
20865
|
+
const phase = goalView.phase;
|
|
20866
|
+
if (phase !== "complete" && phase !== "blocked") return "stale";
|
|
20867
|
+
const state = lifecycle(values);
|
|
20868
|
+
if (!state) return "unverifiable";
|
|
20869
|
+
const cause = state.latestGoalChange;
|
|
20870
|
+
if (!cause) return "unverifiable";
|
|
20871
|
+
if (cause.id !== identity.id || cause.revision !== identity.revision || cause.phase !== phase) return "stale";
|
|
20872
|
+
const sourceTurn = safeNumber(source.turn);
|
|
20873
|
+
if (sourceTurn === void 0) {
|
|
20874
|
+
if (cause.operation !== "block") return "unverifiable";
|
|
20875
|
+
if (state.latestTurn !== cause.turn || state.openTurn !== null) return "stale";
|
|
20876
|
+
return "eligible";
|
|
20877
|
+
}
|
|
20878
|
+
if (state.latestTurn > sourceTurn) return "stale";
|
|
20879
|
+
if (state.latestTurn < sourceTurn || cause.turn > sourceTurn) return "unverifiable";
|
|
20880
|
+
if (phase === "blocked") {
|
|
20881
|
+
if (cause.operation !== "block" || cause.turn !== sourceTurn) return "stale";
|
|
20882
|
+
if (state.openTurn === sourceTurn) return "eligible";
|
|
20883
|
+
const ended = state.lastTurnEnd;
|
|
20884
|
+
return ended?.turn === sourceTurn && (ended.reason === "completed" || ended.reason === "blocked" || ended.reason === "error") ? "eligible" : "unverifiable";
|
|
20885
|
+
}
|
|
20886
|
+
if (cause.operation !== "complete") return "stale";
|
|
20887
|
+
const ended = state.lastTurnEnd;
|
|
20888
|
+
return ended?.turn === sourceTurn && ended.reason === "completed" ? "eligible" : "unverifiable";
|
|
20889
|
+
}
|
|
20890
|
+
var PushReconciler = class {
|
|
20891
|
+
#ctx;
|
|
20892
|
+
constructor(ctx) {
|
|
20893
|
+
this.#ctx = ctx;
|
|
20894
|
+
}
|
|
20895
|
+
registerProjection() {
|
|
20896
|
+
const dispose = this.#ctx.sessionProjections?.register?.(mobilePushLifecycleProjectionDefinition);
|
|
20897
|
+
if (typeof dispose !== "function") throw new Error("public session projection registration unavailable");
|
|
20898
|
+
return dispose;
|
|
20899
|
+
}
|
|
20900
|
+
async reconcile(source, liveProof) {
|
|
20901
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20902
|
+
if (liveProof !== "unverifiable") return liveProof;
|
|
20903
|
+
if (source.category === "plan" || source.category === "approval") return "unverifiable";
|
|
20904
|
+
if (source.category === "question" && !source.callId) return "unverifiable";
|
|
20905
|
+
const agent = this.#ctx.agents?.get?.(source.sessionId);
|
|
20906
|
+
if (agent) {
|
|
20907
|
+
const values = projectionValues(this.#ctx, agent);
|
|
20908
|
+
return values ? this.#verify(source, values, agent) : "unverifiable";
|
|
20909
|
+
}
|
|
20910
|
+
let observation;
|
|
20911
|
+
let result = "unverifiable";
|
|
20912
|
+
try {
|
|
20913
|
+
observation = await this.#ctx.sessionQuery?.observeSession?.(source.sessionId, { projectionMode: "all" });
|
|
20914
|
+
const values = observation?.projections?.values;
|
|
20915
|
+
if (values) result = this.#verify(source, values, void 0);
|
|
20916
|
+
} catch (error) {
|
|
20917
|
+
result = isRecord(error) && error.code === "SESSION_QUERY_SESSION_NOT_FOUND" ? "stale" : "unverifiable";
|
|
20918
|
+
} finally {
|
|
20919
|
+
try {
|
|
20920
|
+
observation?.[Symbol.dispose]();
|
|
20921
|
+
} catch {
|
|
20922
|
+
result = "unverifiable";
|
|
20923
|
+
}
|
|
20924
|
+
}
|
|
20925
|
+
return source.expiresAt <= Date.now() ? "stale" : result;
|
|
20926
|
+
}
|
|
20927
|
+
#verify(source, values, agent) {
|
|
20928
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20929
|
+
if (source.category === "question") return verifyQuestion(source, values, agent);
|
|
20930
|
+
if (source.category === "goal") return verifyGoal(source, values);
|
|
20931
|
+
return verifyOutcome(source, values, agent);
|
|
20932
|
+
}
|
|
20933
|
+
};
|
|
20934
|
+
//#endregion
|
|
20935
|
+
//#region src/mobile-push/host.ts
|
|
20936
|
+
function hasContract(ctx) {
|
|
20937
|
+
return typeof ctx.on === "function" && typeof ctx.sessionProjections?.stateOf === "function" && typeof ctx.sessionProjections.snapshot === "function" && typeof ctx.sessionProjections.register === "function" && typeof ctx.sessionQuery?.observeSession === "function" && typeof ctx.agents?.get === "function" && typeof ctx.agents.list === "function" && typeof ctx.agents.roots === "function" && typeof ctx.agents.isOwnedBy === "function" && ctx.userQuestions !== void 0 && ctx.approval !== void 0;
|
|
20938
|
+
}
|
|
20939
|
+
const MAINTENANCE_INTERVAL_MS = 3e4;
|
|
20940
|
+
function createPushHost(ctx) {
|
|
20941
|
+
let available = hasContract(ctx);
|
|
20942
|
+
const reconciler = new PushReconciler(ctx);
|
|
20943
|
+
let lifecycleProjectionDisposer;
|
|
20944
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
20945
|
+
const changeSubscribers = /* @__PURE__ */ new Set();
|
|
20946
|
+
const disposers = [];
|
|
20947
|
+
let questionDisposer;
|
|
20948
|
+
let approvalDisposer;
|
|
20949
|
+
const add = (source) => {
|
|
20950
|
+
if (closed || !available) return;
|
|
20951
|
+
for (const callback of subscribers) try {
|
|
20952
|
+
callback(source);
|
|
20953
|
+
} catch {}
|
|
20954
|
+
};
|
|
20955
|
+
const requests = new RequestLifetimes(ctx, add);
|
|
20956
|
+
const outcomes = new OutcomeLifetimes(ctx, add);
|
|
20957
|
+
let closed = false;
|
|
20958
|
+
const maintenanceTimer = available ? setInterval(() => {
|
|
20959
|
+
try {
|
|
20960
|
+
requests.sweep();
|
|
20961
|
+
outcomes.sweep();
|
|
20962
|
+
} catch {}
|
|
20963
|
+
}, MAINTENANCE_INTERVAL_MS) : void 0;
|
|
20964
|
+
if (available) try {
|
|
20965
|
+
lifecycleProjectionDisposer = reconciler.registerProjection();
|
|
20966
|
+
} catch {
|
|
20967
|
+
available = false;
|
|
20968
|
+
}
|
|
20969
|
+
const disposeLifecycleProjection = () => {
|
|
20970
|
+
const dispose = lifecycleProjectionDisposer;
|
|
20971
|
+
lifecycleProjectionDisposer = void 0;
|
|
20972
|
+
try {
|
|
20973
|
+
dispose?.();
|
|
20974
|
+
} catch {}
|
|
20975
|
+
};
|
|
20976
|
+
const register = (event, listener, prepend = false) => {
|
|
20977
|
+
try {
|
|
20978
|
+
const dispose = ctx.on(event, listener, {
|
|
20979
|
+
global: true,
|
|
20980
|
+
...prepend ? { prepend: true } : {}
|
|
20981
|
+
});
|
|
20982
|
+
return typeof dispose === "function" ? dispose : void 0;
|
|
20983
|
+
} catch {
|
|
20984
|
+
return;
|
|
20985
|
+
}
|
|
20986
|
+
};
|
|
20987
|
+
const questionObserver = function(request, next) {
|
|
20988
|
+
return requests.observeQuestion(request?.agent ?? this, request, next);
|
|
20989
|
+
};
|
|
20990
|
+
const approvalObserver = function(request, next) {
|
|
20991
|
+
return requests.observeApproval(request?.agent ?? this, request, next);
|
|
20992
|
+
};
|
|
20993
|
+
const refreshWaterfallObservers = () => {
|
|
20994
|
+
questionDisposer?.();
|
|
20995
|
+
approvalDisposer?.();
|
|
20996
|
+
questionDisposer = void 0;
|
|
20997
|
+
approvalDisposer = void 0;
|
|
20998
|
+
const question = register("user-questions/request", questionObserver, true);
|
|
20999
|
+
const approval = register("approval/request", approvalObserver, true);
|
|
21000
|
+
if (!question || !approval) {
|
|
21001
|
+
try {
|
|
21002
|
+
question?.();
|
|
21003
|
+
} catch {}
|
|
21004
|
+
try {
|
|
21005
|
+
approval?.();
|
|
21006
|
+
} catch {}
|
|
21007
|
+
available = false;
|
|
21008
|
+
return false;
|
|
21009
|
+
}
|
|
21010
|
+
questionDisposer = question;
|
|
21011
|
+
approvalDisposer = approval;
|
|
21012
|
+
return true;
|
|
21013
|
+
};
|
|
21014
|
+
const listen = (event, listener) => {
|
|
21015
|
+
const dispose = register(event, listener);
|
|
21016
|
+
if (!dispose) return false;
|
|
21017
|
+
disposers.push(dispose);
|
|
21018
|
+
return true;
|
|
21019
|
+
};
|
|
21020
|
+
const disable = () => {
|
|
21021
|
+
available = false;
|
|
21022
|
+
disposeLifecycleProjection();
|
|
21023
|
+
questionDisposer?.();
|
|
21024
|
+
approvalDisposer?.();
|
|
21025
|
+
questionDisposer = void 0;
|
|
21026
|
+
approvalDisposer = void 0;
|
|
21027
|
+
};
|
|
21028
|
+
if (available && refreshWaterfallObservers()) {
|
|
21029
|
+
const agentCreated = listen("agent/created", () => {
|
|
21030
|
+
if (!refreshWaterfallObservers()) disable();
|
|
21031
|
+
});
|
|
21032
|
+
const sessionEvents = listen("session/event", (session, event) => {
|
|
21033
|
+
if (event?.type === "turn/start" || event?.type === "agent/inbox/inserted" || event?.type === "agent/inbox/spliced") requests.invalidateSession(session.id, { preserveTimed: true });
|
|
21034
|
+
outcomes.onSessionEvent(session, event);
|
|
21035
|
+
if (event?.type === "user/message" && event?.data?.source?.kind === "user-question-reply") requests.queuedReply(session.id, event.data.source.callId);
|
|
21036
|
+
for (const callback of changeSubscribers) try {
|
|
21037
|
+
callback(session.id);
|
|
21038
|
+
} catch {}
|
|
21039
|
+
});
|
|
21040
|
+
const goalEvents = listen("goal/changed", (payload) => {
|
|
21041
|
+
outcomes.onGoal(payload);
|
|
21042
|
+
const id = payload?.agent?.id;
|
|
21043
|
+
if (id) for (const callback of changeSubscribers) try {
|
|
21044
|
+
callback(id);
|
|
21045
|
+
} catch {}
|
|
21046
|
+
});
|
|
21047
|
+
if (!agentCreated || !sessionEvents || !goalEvents) disable();
|
|
21048
|
+
listen("agent/inbox/inserted", (payload) => {
|
|
21049
|
+
const agent = payload?.agent;
|
|
21050
|
+
const message = payload?.message;
|
|
21051
|
+
if (agent?.id && message?.source?.kind === "user-question-reply" && typeof message.source.callId === "string") requests.queuedReply(agent.id, message.source.callId);
|
|
21052
|
+
if (agent?.id) {
|
|
21053
|
+
requests.invalidateSession(agent.id, { preserveTimed: true });
|
|
21054
|
+
outcomes.onSessionEvent(agent, { type: "agent/inbox/inserted" });
|
|
21055
|
+
}
|
|
21056
|
+
});
|
|
21057
|
+
listen("agent/error", (payload) => outcomes.onAgentError(payload));
|
|
21058
|
+
listen("agent/aborted", (payload) => {
|
|
21059
|
+
const id = payload?.agent?.id;
|
|
21060
|
+
if (id) {
|
|
21061
|
+
requests.invalidateSession(id);
|
|
21062
|
+
outcomes.onSessionEvent(payload.agent, {
|
|
21063
|
+
type: "turn/end",
|
|
21064
|
+
data: {
|
|
21065
|
+
turn: payload.turn,
|
|
21066
|
+
reason: {
|
|
21067
|
+
kind: "aborted",
|
|
21068
|
+
reason: payload.reason
|
|
21069
|
+
}
|
|
21070
|
+
}
|
|
21071
|
+
});
|
|
21072
|
+
}
|
|
21073
|
+
});
|
|
21074
|
+
} else disable();
|
|
21075
|
+
const reconcile = async (source) => {
|
|
21076
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
21077
|
+
if (!available || closed) return "unverifiable";
|
|
21078
|
+
const liveProof = source.category === "question" || source.category === "plan" || source.category === "approval" ? requests.reconcileLive(source) : outcomes.reconcileLive(source);
|
|
21079
|
+
return reconciler.reconcile(source, liveProof);
|
|
21080
|
+
};
|
|
21081
|
+
return {
|
|
21082
|
+
get available() {
|
|
21083
|
+
return available;
|
|
21084
|
+
},
|
|
21085
|
+
subscribe(onSource, onChange) {
|
|
21086
|
+
if (closed) return () => void 0;
|
|
21087
|
+
subscribers.add(onSource);
|
|
21088
|
+
changeSubscribers.add(onChange);
|
|
21089
|
+
return () => {
|
|
21090
|
+
subscribers.delete(onSource);
|
|
21091
|
+
changeSubscribers.delete(onChange);
|
|
21092
|
+
};
|
|
21093
|
+
},
|
|
21094
|
+
reconcile,
|
|
21095
|
+
async eligible(source) {
|
|
21096
|
+
return await reconcile(source) === "eligible";
|
|
21097
|
+
},
|
|
21098
|
+
async sessionExists(sessionId) {
|
|
21099
|
+
if (!available || closed || !sessionId) return false;
|
|
21100
|
+
let observation;
|
|
21101
|
+
let exists = false;
|
|
21102
|
+
try {
|
|
21103
|
+
observation = await ctx.sessionQuery?.observeSession?.(sessionId, { projectionMode: "all" });
|
|
21104
|
+
exists = observation !== void 0;
|
|
21105
|
+
} catch {
|
|
21106
|
+
exists = false;
|
|
21107
|
+
} finally {
|
|
21108
|
+
try {
|
|
21109
|
+
observation?.[Symbol.dispose]();
|
|
21110
|
+
} catch {
|
|
21111
|
+
exists = false;
|
|
21112
|
+
}
|
|
21113
|
+
}
|
|
21114
|
+
return exists && available && !closed;
|
|
21115
|
+
},
|
|
21116
|
+
async resolveTarget(source) {
|
|
21117
|
+
if (!available) return void 0;
|
|
21118
|
+
const current = ctx.agents?.get?.(source.targetSessionId);
|
|
21119
|
+
return current ? {
|
|
21120
|
+
sessionId: current.id,
|
|
21121
|
+
interaction: "current"
|
|
21122
|
+
} : {
|
|
21123
|
+
sessionId: source.targetSessionId,
|
|
21124
|
+
interaction: "resolved"
|
|
21125
|
+
};
|
|
21126
|
+
},
|
|
21127
|
+
async close() {
|
|
21128
|
+
if (closed) return;
|
|
21129
|
+
closed = true;
|
|
21130
|
+
if (maintenanceTimer) clearInterval(maintenanceTimer);
|
|
21131
|
+
subscribers.clear();
|
|
21132
|
+
changeSubscribers.clear();
|
|
21133
|
+
requests.close();
|
|
21134
|
+
outcomes.close();
|
|
21135
|
+
questionDisposer?.();
|
|
21136
|
+
approvalDisposer?.();
|
|
21137
|
+
questionDisposer = void 0;
|
|
21138
|
+
approvalDisposer = void 0;
|
|
21139
|
+
for (const dispose of disposers.splice(0)) try {
|
|
21140
|
+
dispose();
|
|
21141
|
+
} catch {}
|
|
21142
|
+
disposeLifecycleProjection();
|
|
21143
|
+
}
|
|
21144
|
+
};
|
|
21145
|
+
}
|
|
21146
|
+
//#endregion
|
|
21147
|
+
//#region src/mobile-push/endpoint.ts
|
|
21148
|
+
const MAX_ENDPOINT_BYTES = 2048;
|
|
21149
|
+
const APPLE_SUFFIX = ".push.apple.com";
|
|
21150
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/u;
|
|
21151
|
+
/** Canonical URL identity for push endpoint ownership; policy checks live elsewhere. */
|
|
21152
|
+
function canonicalPushEndpoint(value) {
|
|
21153
|
+
try {
|
|
21154
|
+
return new URL(value).href;
|
|
21155
|
+
} catch {
|
|
21156
|
+
throw new TypeError("push_endpoint_invalid");
|
|
21157
|
+
}
|
|
21158
|
+
}
|
|
21159
|
+
function invalid() {
|
|
21160
|
+
throw new TypeError("push_subscription_invalid");
|
|
21161
|
+
}
|
|
21162
|
+
function decodeBase64url(value) {
|
|
21163
|
+
if (typeof value !== "string" || !BASE64URL.test(value)) return invalid();
|
|
21164
|
+
const decoded = Buffer.from(value, "base64url");
|
|
21165
|
+
if (decoded.toString("base64url") !== value) return invalid();
|
|
21166
|
+
return decoded;
|
|
21167
|
+
}
|
|
21168
|
+
function validateEndpoint(value) {
|
|
21169
|
+
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_ENDPOINT_BYTES) return invalid();
|
|
21170
|
+
let url;
|
|
21171
|
+
try {
|
|
21172
|
+
url = new URL(value);
|
|
21173
|
+
} catch {
|
|
21174
|
+
return invalid();
|
|
21175
|
+
}
|
|
21176
|
+
const hostname = url.hostname.toLowerCase();
|
|
21177
|
+
const authority = /^https:\/\/([^/?#]*)/iu.exec(value)?.[1] ?? "";
|
|
21178
|
+
if (url.protocol !== "https:" || authority.includes("@") || url.username !== "" || url.password !== "" || value.includes("#") || url.hash !== "" || url.port !== "" && url.port !== "443" || url.pathname === "" || url.pathname === "/" || !hostname.endsWith(APPLE_SUFFIX) || hostname.length <= 15 || isIP(hostname) !== 0 || hostname.endsWith(".") || hostname.split(".").some((label) => label.length < 1 || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label))) return invalid();
|
|
21179
|
+
const canonical = canonicalPushEndpoint(value);
|
|
21180
|
+
if (Buffer.byteLength(canonical, "utf8") > MAX_ENDPOINT_BYTES) return invalid();
|
|
21181
|
+
return canonical;
|
|
21182
|
+
}
|
|
21183
|
+
function validateP256PublicKey(value) {
|
|
21184
|
+
const bytes = decodeBase64url(value);
|
|
21185
|
+
if (bytes.length !== 65 || bytes[0] !== 4) return invalid();
|
|
21186
|
+
try {
|
|
21187
|
+
createPublicKey({
|
|
21188
|
+
key: {
|
|
21189
|
+
kty: "EC",
|
|
21190
|
+
crv: "P-256",
|
|
21191
|
+
x: bytes.subarray(1, 33).toString("base64url"),
|
|
21192
|
+
y: bytes.subarray(33, 65).toString("base64url")
|
|
21193
|
+
},
|
|
21194
|
+
format: "jwk"
|
|
21195
|
+
});
|
|
21196
|
+
} catch {
|
|
21197
|
+
return invalid();
|
|
21198
|
+
}
|
|
21199
|
+
return value;
|
|
21200
|
+
}
|
|
21201
|
+
/** Validate an untrusted browser subscription before persisting or sending it. */
|
|
21202
|
+
function validatePushSubscription(value) {
|
|
21203
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return invalid();
|
|
21204
|
+
const input = value;
|
|
21205
|
+
if (input.keys === null || typeof input.keys !== "object" || Array.isArray(input.keys)) return invalid();
|
|
21206
|
+
const keys = input.keys;
|
|
21207
|
+
const p256dh = validateP256PublicKey(keys.p256dh);
|
|
21208
|
+
if (decodeBase64url(keys.auth).length !== 16) return invalid();
|
|
21209
|
+
return Object.freeze({
|
|
21210
|
+
endpoint: validateEndpoint(input.endpoint),
|
|
21211
|
+
keys: Object.freeze({
|
|
21212
|
+
p256dh,
|
|
21213
|
+
auth: keys.auth
|
|
21214
|
+
})
|
|
21215
|
+
});
|
|
21216
|
+
}
|
|
21217
|
+
function ipv6Value(address) {
|
|
21218
|
+
const [leftText = "", rightText] = address.toLowerCase().split("::");
|
|
21219
|
+
const left = leftText === "" ? [] : leftText.split(":");
|
|
21220
|
+
const right = rightText === void 0 || rightText === "" ? [] : rightText.split(":");
|
|
21221
|
+
const omitted = 8 - left.length - right.length;
|
|
21222
|
+
const groups = [
|
|
21223
|
+
...left,
|
|
21224
|
+
...Array.from({ length: omitted }, () => "0"),
|
|
21225
|
+
...right
|
|
21226
|
+
];
|
|
21227
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/u.test(group))) throw new Error("invalid IPv6");
|
|
21228
|
+
return groups.reduce((n, group) => n << 16n | BigInt(`0x${group}`), 0n);
|
|
21229
|
+
}
|
|
21230
|
+
/** Reject non-global, special-purpose and IPv4-mapped DNS answers. */
|
|
21231
|
+
function isGloballyRoutableAddress(address) {
|
|
21232
|
+
const family = isIP(address);
|
|
21233
|
+
if (family === 4) return isGloballyRoutableIpv4(address);
|
|
21234
|
+
if (family !== 6 || address.includes("%") || address.includes(".")) return false;
|
|
21235
|
+
try {
|
|
21236
|
+
const value = ipv6Value(address);
|
|
21237
|
+
const first16 = value >> 112n;
|
|
21238
|
+
if (value >> 125n !== 1n) return false;
|
|
21239
|
+
if (first16 === 8193n && value >> 105n === 8193n << 7n) return false;
|
|
21240
|
+
if (first16 === 8193n && value >> 96n === 536939960n) return false;
|
|
21241
|
+
if (first16 === 8194n) return false;
|
|
21242
|
+
if (first16 === 16383n && (value >> 108n & 15n) === 0n) return false;
|
|
21243
|
+
return true;
|
|
21244
|
+
} catch {
|
|
21245
|
+
return false;
|
|
21246
|
+
}
|
|
21247
|
+
}
|
|
21248
|
+
async function systemResolver(hostname) {
|
|
21249
|
+
return (await lookup(hostname, {
|
|
21250
|
+
all: true,
|
|
21251
|
+
verbatim: true
|
|
21252
|
+
})).filter((item) => item.family === 4 || item.family === 6);
|
|
21253
|
+
}
|
|
21254
|
+
/** Resolve and vet every answer before a caller opens a socket. */
|
|
21255
|
+
async function resolveAppleEndpoint(endpoint, resolver = systemResolver) {
|
|
21256
|
+
const url = new URL(validateEndpoint(endpoint));
|
|
21257
|
+
const addresses = await resolver(url.hostname);
|
|
21258
|
+
if (addresses.length === 0 || addresses.some((item) => !isGloballyRoutableAddress(item.address) || item.family !== 4 && item.family !== 6 || isIP(item.address) !== item.family)) throw new TypeError("push_endpoint_address_rejected");
|
|
21259
|
+
return Object.freeze({
|
|
21260
|
+
url,
|
|
21261
|
+
addresses: Object.freeze([...addresses])
|
|
21262
|
+
});
|
|
21263
|
+
}
|
|
21264
|
+
//#endregion
|
|
21265
|
+
//#region src/mobile-push/store.ts
|
|
21266
|
+
const MAX_STATE_BYTES = 4194304;
|
|
21267
|
+
const emptySnapshot = () => ({
|
|
21268
|
+
version: 1,
|
|
21269
|
+
ownerGenerations: [],
|
|
21270
|
+
registrations: [],
|
|
21271
|
+
pending: [],
|
|
21272
|
+
receipts: [],
|
|
21273
|
+
references: []
|
|
21274
|
+
});
|
|
21275
|
+
async function ensurePrivateDirectory(directory) {
|
|
21276
|
+
const absolute = resolve(directory);
|
|
21277
|
+
await mkdir(absolute, {
|
|
21278
|
+
recursive: true,
|
|
21279
|
+
mode: 448
|
|
21280
|
+
});
|
|
21281
|
+
const stat = await lstat(absolute);
|
|
21282
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("push state parent must be a managed non-symlink directory");
|
|
21283
|
+
if (process.platform !== "win32" && (stat.mode & 63) !== 0) throw new Error("push state parent permissions must be owner-only");
|
|
21284
|
+
}
|
|
21285
|
+
function object(value) {
|
|
21286
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21287
|
+
}
|
|
21288
|
+
function list(value) {
|
|
21289
|
+
return Array.isArray(value);
|
|
21290
|
+
}
|
|
21291
|
+
function validKey(value) {
|
|
21292
|
+
return object(value) && only(value, [
|
|
21293
|
+
"publicKey",
|
|
21294
|
+
"privateKey",
|
|
21295
|
+
"fingerprint"
|
|
21296
|
+
]) && typeof value.publicKey === "string" && value.publicKey.length > 0 && typeof value.privateKey === "string" && value.privateKey.length > 0 && typeof value.fingerprint === "string" && value.fingerprint.length > 0;
|
|
21297
|
+
}
|
|
21298
|
+
function owner(value) {
|
|
21299
|
+
return object(value) && typeof value.scopeId === "string" && value.scopeId.length > 0 && typeof value.deviceId === "string" && value.deviceId.length > 0 && typeof value.origin === "string" && value.origin.length > 0;
|
|
21300
|
+
}
|
|
21301
|
+
function recipient(value) {
|
|
21302
|
+
return owner(value) && typeof value.installationId === "string" && value.installationId.length > 0;
|
|
21303
|
+
}
|
|
21304
|
+
function only(value, keys) {
|
|
21305
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
21306
|
+
}
|
|
21307
|
+
function validSource(value) {
|
|
21308
|
+
if (!object(value) || !only(value, [
|
|
21309
|
+
"sourceId",
|
|
21310
|
+
"sessionId",
|
|
21311
|
+
"targetSessionId",
|
|
21312
|
+
"category",
|
|
21313
|
+
"createdAt",
|
|
21314
|
+
"expiresAt",
|
|
21315
|
+
"lifetime",
|
|
21316
|
+
"turn",
|
|
21317
|
+
"callId",
|
|
21318
|
+
"goal"
|
|
21319
|
+
])) return false;
|
|
21320
|
+
return [
|
|
21321
|
+
value.sourceId,
|
|
21322
|
+
value.sessionId,
|
|
21323
|
+
value.targetSessionId
|
|
21324
|
+
].every((v) => typeof v === "string" && v.length > 0 && v.length <= 4096) && [
|
|
21325
|
+
"question",
|
|
21326
|
+
"plan",
|
|
21327
|
+
"approval",
|
|
21328
|
+
"response",
|
|
21329
|
+
"goal",
|
|
21330
|
+
"failure"
|
|
21331
|
+
].includes(String(value.category)) && Number.isFinite(value.createdAt) && Number.isFinite(value.expiresAt) && [
|
|
21332
|
+
"live",
|
|
21333
|
+
"projected",
|
|
21334
|
+
"committed"
|
|
21335
|
+
].includes(String(value.lifetime)) && (value.turn === void 0 || Number.isSafeInteger(value.turn)) && (value.callId === void 0 || typeof value.callId === "string") && (value.goal === void 0 || object(value.goal) && only(value.goal, ["id", "revision"]) && typeof value.goal.id === "string" && Number.isSafeInteger(value.goal.revision));
|
|
21336
|
+
}
|
|
21337
|
+
function scopedCounts(rows, scope, maximum = 1024) {
|
|
21338
|
+
const counts = /* @__PURE__ */ new Map();
|
|
21339
|
+
for (const row of rows) {
|
|
21340
|
+
const key = scope(row);
|
|
21341
|
+
const next = (counts.get(key) ?? 0) + 1;
|
|
21342
|
+
counts.set(key, next);
|
|
21343
|
+
if (next > maximum) return false;
|
|
21344
|
+
}
|
|
21345
|
+
return true;
|
|
21346
|
+
}
|
|
21347
|
+
function registration(value) {
|
|
21348
|
+
return recipient(value) && object(value) && only(value, [
|
|
21349
|
+
"scopeId",
|
|
21350
|
+
"deviceId",
|
|
21351
|
+
"origin",
|
|
21352
|
+
"installationId",
|
|
21353
|
+
"subscription",
|
|
21354
|
+
"locale",
|
|
21355
|
+
"preferences",
|
|
21356
|
+
"generation",
|
|
21357
|
+
"keyFingerprint",
|
|
21358
|
+
"enabled",
|
|
21359
|
+
"health"
|
|
21360
|
+
]) && object(value.subscription) && only(value.subscription, ["endpoint", "keys"]) && typeof value.subscription.endpoint === "string" && value.subscription.endpoint.length > 0 && object(value.subscription.keys) && only(value.subscription.keys, ["p256dh", "auth"]) && typeof value.subscription.keys.p256dh === "string" && typeof value.subscription.keys.auth === "string" && (value.locale === "en" || value.locale === "zh-CN" || value.locale === "it") && object(value.preferences) && only(value.preferences, [
|
|
21361
|
+
"question",
|
|
21362
|
+
"plan",
|
|
21363
|
+
"approval",
|
|
21364
|
+
"response",
|
|
21365
|
+
"goal",
|
|
21366
|
+
"failure"
|
|
21367
|
+
]) && [
|
|
21368
|
+
"question",
|
|
21369
|
+
"plan",
|
|
21370
|
+
"approval",
|
|
21371
|
+
"response",
|
|
21372
|
+
"goal",
|
|
21373
|
+
"failure"
|
|
21374
|
+
].every((k) => typeof value.preferences[k] === "boolean") && Number.isSafeInteger(value.generation) && Number(value.generation) > 0 && typeof value.keyFingerprint === "string" && typeof value.enabled === "boolean" && [
|
|
21375
|
+
"ready",
|
|
21376
|
+
"suspended",
|
|
21377
|
+
"repair-required"
|
|
21378
|
+
].includes(String(value.health));
|
|
21379
|
+
}
|
|
21380
|
+
function parsePushSnapshot(value) {
|
|
21381
|
+
if (!object(value) || !only(value, [
|
|
21382
|
+
"version",
|
|
21383
|
+
"keys",
|
|
21384
|
+
"ownerGenerations",
|
|
21385
|
+
"ownerGenerationFloor",
|
|
21386
|
+
"registrations",
|
|
21387
|
+
"pending",
|
|
21388
|
+
"receipts",
|
|
21389
|
+
"references"
|
|
21390
|
+
]) || value.version !== 1) throw new Error("push state has an unsupported version");
|
|
21391
|
+
for (const field of [
|
|
21392
|
+
"ownerGenerations",
|
|
21393
|
+
"registrations",
|
|
21394
|
+
"pending",
|
|
21395
|
+
"receipts",
|
|
21396
|
+
"references"
|
|
21397
|
+
]) if (!list(value[field])) throw new Error(`push state ${field} must be an array`);
|
|
21398
|
+
if (value.ownerGenerationFloor !== void 0 && (!Number.isSafeInteger(value.ownerGenerationFloor) || Number(value.ownerGenerationFloor) < 0)) throw new Error("push state has an invalid owner generation floor");
|
|
21399
|
+
if (value.keys !== void 0 && !validKey(value.keys)) throw new Error("push state contains invalid key material");
|
|
21400
|
+
const owners = value.ownerGenerations;
|
|
21401
|
+
const registrations = value.registrations;
|
|
21402
|
+
const pending = value.pending;
|
|
21403
|
+
const receipts = value.receipts;
|
|
21404
|
+
const references = value.references;
|
|
21405
|
+
if (!scopedCounts(owners, (row) => object(row) ? row.scopeId : void 0) || !scopedCounts(registrations, (row) => object(row) ? row.scopeId : void 0, 256) || !scopedCounts(pending, (row) => object(row) && object(row.key) ? row.key.scopeId : void 0, 256) || !scopedCounts(receipts, (row) => object(row) && object(row.key) ? row.key.scopeId : void 0) || !scopedCounts(references, (row) => object(row) && object(row.key) ? row.key.scopeId : void 0)) throw new Error("push state exceeds a storage limit");
|
|
21406
|
+
if (!owners.every((o) => owner(o) && only(o, [
|
|
21407
|
+
"scopeId",
|
|
21408
|
+
"deviceId",
|
|
21409
|
+
"origin",
|
|
21410
|
+
"generation"
|
|
21411
|
+
]) && Number.isSafeInteger(o.generation) && Number(o.generation) >= 0) || !registrations.every(registration) || !pending.every((i) => object(i) && only(i, [
|
|
21412
|
+
"id",
|
|
21413
|
+
"key",
|
|
21414
|
+
"source",
|
|
21415
|
+
"ownerGeneration",
|
|
21416
|
+
"recipientGeneration",
|
|
21417
|
+
"attempts",
|
|
21418
|
+
"nextAttemptAt"
|
|
21419
|
+
]) && typeof i.id === "string" && recipient(i.key) && object(i.key) && only(i.key, [
|
|
21420
|
+
"scopeId",
|
|
21421
|
+
"deviceId",
|
|
21422
|
+
"origin",
|
|
21423
|
+
"installationId"
|
|
21424
|
+
]) && validSource(i.source) && Number.isSafeInteger(i.ownerGeneration) && Number.isSafeInteger(i.recipientGeneration) && Number.isSafeInteger(i.attempts) && Number.isFinite(i.nextAttemptAt)) || !receipts.every((r) => object(r) && only(r, [
|
|
21425
|
+
"sourceId",
|
|
21426
|
+
"key",
|
|
21427
|
+
"recipientGeneration",
|
|
21428
|
+
"expiresAt"
|
|
21429
|
+
]) && typeof r.sourceId === "string" && recipient(r.key) && object(r.key) && only(r.key, [
|
|
21430
|
+
"scopeId",
|
|
21431
|
+
"deviceId",
|
|
21432
|
+
"origin",
|
|
21433
|
+
"installationId"
|
|
21434
|
+
]) && Number.isSafeInteger(r.recipientGeneration) && Number.isFinite(r.expiresAt)) || !references.every((r) => object(r) && only(r, [
|
|
21435
|
+
"reference",
|
|
21436
|
+
"key",
|
|
21437
|
+
"source",
|
|
21438
|
+
"expiresAt"
|
|
21439
|
+
]) && typeof r.reference === "string" && recipient(r.key) && object(r.key) && only(r.key, [
|
|
21440
|
+
"scopeId",
|
|
21441
|
+
"deviceId",
|
|
21442
|
+
"origin",
|
|
21443
|
+
"installationId"
|
|
21444
|
+
]) && validSource(r.source) && r.expiresAt === r.source.expiresAt)) throw new Error("push state contains invalid records");
|
|
21445
|
+
const ownerKeys = owners.map((o) => JSON.stringify([
|
|
21446
|
+
o.scopeId,
|
|
21447
|
+
o.deviceId,
|
|
21448
|
+
o.origin
|
|
21449
|
+
]));
|
|
21450
|
+
const validRegistrations = registrations;
|
|
21451
|
+
const regKeys = validRegistrations.map((reg) => JSON.stringify([
|
|
21452
|
+
reg.scopeId,
|
|
21453
|
+
reg.deviceId,
|
|
21454
|
+
reg.origin,
|
|
21455
|
+
reg.installationId
|
|
21456
|
+
]));
|
|
21457
|
+
const endpoints = validRegistrations.map((reg) => canonicalPushEndpoint(reg.subscription.endpoint));
|
|
21458
|
+
if (new Set(ownerKeys).size !== owners.length || new Set(regKeys).size !== registrations.length || new Set(endpoints).size !== endpoints.length) throw new Error("push state contains duplicate ownership");
|
|
21459
|
+
for (const reg of validRegistrations) if (validRegistrations.filter((other) => other.scopeId === reg.scopeId && other.deviceId === reg.deviceId).length > 4) throw new Error("push state exceeds the per-device registration limit");
|
|
21460
|
+
return structuredClone(value);
|
|
21461
|
+
}
|
|
21462
|
+
var JsonPushStore = class {
|
|
21463
|
+
file;
|
|
21464
|
+
constructor(file) {
|
|
21465
|
+
this.file = file;
|
|
21466
|
+
}
|
|
21467
|
+
async load() {
|
|
21468
|
+
try {
|
|
21469
|
+
const parent = await lstat(dirname(this.file));
|
|
21470
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("push state parent must be a managed non-symlink directory");
|
|
21471
|
+
if (process.platform !== "win32" && (parent.mode & 63) !== 0) throw new Error("push state parent permissions must be owner-only");
|
|
21472
|
+
} catch (error) {
|
|
21473
|
+
if (error.code === "ENOENT") return emptySnapshot();
|
|
21474
|
+
throw error;
|
|
21475
|
+
}
|
|
21476
|
+
let info;
|
|
21477
|
+
try {
|
|
21478
|
+
info = await lstat(this.file);
|
|
21479
|
+
} catch (error) {
|
|
21480
|
+
if (error.code === "ENOENT") return emptySnapshot();
|
|
21481
|
+
throw error;
|
|
21482
|
+
}
|
|
21483
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_STATE_BYTES) throw new Error("push state must be a regular file no larger than 4 MiB");
|
|
21484
|
+
await restrictPrivateFile(this.file);
|
|
21485
|
+
let parsed;
|
|
21486
|
+
try {
|
|
21487
|
+
parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
21488
|
+
} catch (error) {
|
|
21489
|
+
throw new Error("push state is not valid JSON", { cause: error });
|
|
21490
|
+
}
|
|
21491
|
+
return parsePushSnapshot(parsed);
|
|
21492
|
+
}
|
|
21493
|
+
async save(snapshot) {
|
|
21494
|
+
const safe = parsePushSnapshot(snapshot);
|
|
21495
|
+
const serialized = `${JSON.stringify(safe)}\n`;
|
|
21496
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error("push state exceeds 4 MiB");
|
|
21497
|
+
const directory = dirname(this.file);
|
|
21498
|
+
await ensurePrivateDirectory(directory);
|
|
21499
|
+
try {
|
|
21500
|
+
const current = await lstat(this.file);
|
|
21501
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("push state target must remain a regular file");
|
|
21502
|
+
} catch (error) {
|
|
21503
|
+
if (error.code !== "ENOENT") throw error;
|
|
21504
|
+
}
|
|
21505
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
21506
|
+
try {
|
|
21507
|
+
await writeFile(temporary, "", {
|
|
21508
|
+
encoding: "utf8",
|
|
21509
|
+
flag: "wx",
|
|
21510
|
+
mode: 384
|
|
21511
|
+
});
|
|
21512
|
+
await restrictPrivateFile(temporary);
|
|
21513
|
+
await writeFile(temporary, serialized, {
|
|
21514
|
+
encoding: "utf8",
|
|
21515
|
+
flag: "r+"
|
|
21516
|
+
});
|
|
21517
|
+
await rename(temporary, this.file);
|
|
21518
|
+
} catch (error) {
|
|
21519
|
+
try {
|
|
21520
|
+
await rm(temporary, { force: true });
|
|
21521
|
+
} catch (cleanupError) {
|
|
21522
|
+
throw new AggregateError([error, cleanupError], "push state write and cleanup both failed");
|
|
21523
|
+
}
|
|
21524
|
+
throw error;
|
|
21525
|
+
}
|
|
21526
|
+
}
|
|
21527
|
+
};
|
|
21528
|
+
//#endregion
|
|
21529
|
+
//#region src/mobile-push/registry.ts
|
|
21530
|
+
const empty = () => ({
|
|
21531
|
+
version: 1,
|
|
21532
|
+
ownerGenerations: [],
|
|
21533
|
+
registrations: [],
|
|
21534
|
+
pending: [],
|
|
21535
|
+
receipts: [],
|
|
21536
|
+
references: []
|
|
21537
|
+
});
|
|
21538
|
+
const idOfOwner = (owner) => JSON.stringify([
|
|
21539
|
+
owner.scopeId,
|
|
21540
|
+
owner.deviceId,
|
|
21541
|
+
owner.origin
|
|
21542
|
+
]);
|
|
21543
|
+
const idOfKey = (key) => JSON.stringify([
|
|
21544
|
+
key.scopeId,
|
|
21545
|
+
key.deviceId,
|
|
21546
|
+
key.origin,
|
|
21547
|
+
key.installationId
|
|
21548
|
+
]);
|
|
21549
|
+
const sameOwner = (a, b) => idOfOwner(a) === idOfOwner(b);
|
|
21550
|
+
function vapidFingerprint(keys) {
|
|
21551
|
+
const rawPublic = Buffer.from(keys.publicKey, "base64url");
|
|
21552
|
+
if (rawPublic.length !== 65 || rawPublic[0] !== 4 || rawPublic.toString("base64url") !== keys.publicKey) throw new Error("invalid uncompressed VAPID public key");
|
|
21553
|
+
const privateBytes = Buffer.from(keys.privateKey, "base64url");
|
|
21554
|
+
if (privateBytes.length !== 32 || privateBytes.toString("base64url") !== keys.privateKey) throw new Error("invalid VAPID private key");
|
|
21555
|
+
const ecdh = createECDH("prime256v1");
|
|
21556
|
+
ecdh.setPrivateKey(privateBytes);
|
|
21557
|
+
if (!ecdh.getPublicKey(void 0, "uncompressed").equals(rawPublic)) throw new Error("VAPID public and private keys do not match");
|
|
21558
|
+
return createHash("sha256").update(rawPublic).digest("hex");
|
|
21559
|
+
}
|
|
21560
|
+
const fingerprint = vapidFingerprint;
|
|
21561
|
+
const defaultPreferences = {
|
|
21562
|
+
question: true,
|
|
21563
|
+
plan: true,
|
|
21564
|
+
approval: true,
|
|
21565
|
+
response: true,
|
|
21566
|
+
goal: true,
|
|
21567
|
+
failure: true
|
|
21568
|
+
};
|
|
21569
|
+
function clone(value) {
|
|
21570
|
+
return structuredClone(value);
|
|
21571
|
+
}
|
|
21572
|
+
function boundedText(value, label) {
|
|
21573
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) throw new Error(`invalid ${label}`);
|
|
21574
|
+
}
|
|
21575
|
+
/** Serializes and durably commits all push ownership and consent mutations. */
|
|
21576
|
+
var PushRegistry = class {
|
|
21577
|
+
store;
|
|
21578
|
+
createKeys;
|
|
21579
|
+
state = empty();
|
|
21580
|
+
initialized = false;
|
|
21581
|
+
closed = false;
|
|
21582
|
+
queue = Promise.resolve();
|
|
21583
|
+
constructor(store, createKeys) {
|
|
21584
|
+
this.store = store;
|
|
21585
|
+
this.createKeys = createKeys;
|
|
21586
|
+
}
|
|
21587
|
+
serialize(operation) {
|
|
21588
|
+
const result = this.queue.then(() => {
|
|
21589
|
+
if (this.closed) throw new Error("push registry is closed");
|
|
21590
|
+
return operation();
|
|
21591
|
+
});
|
|
21592
|
+
this.queue = result.catch(() => void 0);
|
|
21593
|
+
return result;
|
|
21594
|
+
}
|
|
21595
|
+
assertReady() {
|
|
21596
|
+
if (!this.initialized) throw new Error("push registry is not initialized");
|
|
21597
|
+
}
|
|
21598
|
+
ownerGeneration(owner) {
|
|
21599
|
+
return Math.max(this.state.ownerGenerationFloor ?? 0, this.state.ownerGenerations.find((item) => sameOwner(item, owner))?.generation ?? 0);
|
|
21600
|
+
}
|
|
21601
|
+
advanceOwnerGenerationFloor(snapshot, removedRegistrationGenerations = []) {
|
|
21602
|
+
let maximum = snapshot.ownerGenerationFloor ?? 0;
|
|
21603
|
+
for (const row of snapshot.ownerGenerations) maximum = Math.max(maximum, row.generation);
|
|
21604
|
+
for (const generation of removedRegistrationGenerations) maximum = Math.max(maximum, generation);
|
|
21605
|
+
if (!Number.isSafeInteger(maximum) || maximum >= Number.MAX_SAFE_INTEGER) throw new Error("owner generation floor exceeds safe integer range");
|
|
21606
|
+
return {
|
|
21607
|
+
...snapshot,
|
|
21608
|
+
ownerGenerationFloor: maximum + 1
|
|
21609
|
+
};
|
|
21610
|
+
}
|
|
21611
|
+
async commit(next) {
|
|
21612
|
+
await this.store.save(next);
|
|
21613
|
+
this.state = next;
|
|
21614
|
+
}
|
|
21615
|
+
putOwnerGeneration(next, owner, generation) {
|
|
21616
|
+
const rows = [...next.ownerGenerations];
|
|
21617
|
+
const index = rows.findIndex((item) => sameOwner(item, owner));
|
|
21618
|
+
if (index >= 0) rows[index] = {
|
|
21619
|
+
...rows[index],
|
|
21620
|
+
generation
|
|
21621
|
+
};
|
|
21622
|
+
else {
|
|
21623
|
+
if (rows.filter((item) => item.scopeId === owner.scopeId).length >= 1024) throw new Error("owner generation limit reached for authorization scope");
|
|
21624
|
+
rows.push({
|
|
21625
|
+
scopeId: owner.scopeId,
|
|
21626
|
+
deviceId: owner.deviceId,
|
|
21627
|
+
origin: owner.origin,
|
|
21628
|
+
generation
|
|
21629
|
+
});
|
|
21630
|
+
}
|
|
21631
|
+
return {
|
|
21632
|
+
...next,
|
|
21633
|
+
ownerGenerations: rows
|
|
21634
|
+
};
|
|
21635
|
+
}
|
|
21636
|
+
async initialize() {
|
|
21637
|
+
await this.serialize(async () => {
|
|
21638
|
+
let loaded = await this.store.load();
|
|
21639
|
+
let keyIntegrity = false;
|
|
21640
|
+
try {
|
|
21641
|
+
keyIntegrity = !!loaded.keys && loaded.keys.fingerprint === fingerprint(loaded.keys);
|
|
21642
|
+
} catch (_error) {
|
|
21643
|
+
keyIntegrity = false;
|
|
21644
|
+
}
|
|
21645
|
+
if (!keyIntegrity) {
|
|
21646
|
+
if (loaded.registrations.length) {
|
|
21647
|
+
loaded = {
|
|
21648
|
+
...loaded,
|
|
21649
|
+
registrations: loaded.registrations.map((reg) => ({
|
|
21650
|
+
...reg,
|
|
21651
|
+
health: "repair-required"
|
|
21652
|
+
}))
|
|
21653
|
+
};
|
|
21654
|
+
await this.store.save(loaded);
|
|
21655
|
+
} else {
|
|
21656
|
+
const keys = this.createKeys();
|
|
21657
|
+
if (!keys || typeof keys.publicKey !== "string" || !keys.publicKey || typeof keys.privateKey !== "string" || !keys.privateKey) throw new Error("key factory returned invalid VAPID keys");
|
|
21658
|
+
loaded = {
|
|
21659
|
+
...loaded,
|
|
21660
|
+
keys: {
|
|
21661
|
+
...keys,
|
|
21662
|
+
fingerprint: fingerprint(keys)
|
|
21663
|
+
}
|
|
21664
|
+
};
|
|
21665
|
+
await this.store.save(loaded);
|
|
21666
|
+
}
|
|
21667
|
+
}
|
|
21668
|
+
this.state = clone(loaded);
|
|
21669
|
+
this.initialized = true;
|
|
21670
|
+
});
|
|
21671
|
+
}
|
|
21672
|
+
async publicKey() {
|
|
21673
|
+
this.assertReady();
|
|
21674
|
+
if (!this.state.keys || this.state.registrations.some((reg) => reg.health === "repair-required")) throw new Error("push VAPID keys require repair");
|
|
21675
|
+
return this.state.keys.publicKey;
|
|
21676
|
+
}
|
|
21677
|
+
async config(owner, installationId) {
|
|
21678
|
+
this.assertReady();
|
|
21679
|
+
const key = {
|
|
21680
|
+
...owner,
|
|
21681
|
+
installationId
|
|
21682
|
+
};
|
|
21683
|
+
const registration = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21684
|
+
const keyUsable = !!this.state.keys && !this.state.registrations.some((row) => row.health === "repair-required");
|
|
21685
|
+
return {
|
|
21686
|
+
available: keyUsable,
|
|
21687
|
+
...keyUsable ? {
|
|
21688
|
+
publicKey: this.state.keys.publicKey,
|
|
21689
|
+
keyFingerprint: this.state.keys.fingerprint
|
|
21690
|
+
} : {},
|
|
21691
|
+
installationId,
|
|
21692
|
+
ownerGeneration: this.ownerGeneration(owner),
|
|
21693
|
+
registrationGeneration: registration?.generation ?? 0,
|
|
21694
|
+
status: registration?.health === "repair-required" ? "repair-required" : registration?.health === "suspended" ? "suspended" : registration?.enabled ? "enabled" : "disabled",
|
|
21695
|
+
preferences: clone(registration?.preferences ?? defaultPreferences)
|
|
21696
|
+
};
|
|
21697
|
+
}
|
|
21698
|
+
/** Server-internal persistence snapshot; includes private VAPID material and is not a public config projection. */
|
|
21699
|
+
snapshot() {
|
|
21700
|
+
this.assertReady();
|
|
21701
|
+
return clone(this.state);
|
|
21702
|
+
}
|
|
21703
|
+
async transact(update) {
|
|
21704
|
+
await this.serialize(async () => {
|
|
21705
|
+
this.assertReady();
|
|
21706
|
+
const next = update(clone(this.state));
|
|
21707
|
+
if (JSON.stringify(next.keys) !== JSON.stringify(this.state.keys) || next.ownerGenerationFloor !== this.state.ownerGenerationFloor || JSON.stringify(next.ownerGenerations) !== JSON.stringify(this.state.ownerGenerations) || JSON.stringify(next.registrations) !== JSON.stringify(this.state.registrations)) throw new Error("push transactions may only update delivery state");
|
|
21708
|
+
const validated = {
|
|
21709
|
+
...next,
|
|
21710
|
+
...this.pruneExpired(next)
|
|
21711
|
+
};
|
|
21712
|
+
await this.commit(validated);
|
|
21713
|
+
});
|
|
21714
|
+
}
|
|
21715
|
+
async upsert(owner, input) {
|
|
21716
|
+
return this.serialize(async () => {
|
|
21717
|
+
this.assertReady();
|
|
21718
|
+
boundedText(owner.scopeId, "scope");
|
|
21719
|
+
boundedText(owner.deviceId, "device");
|
|
21720
|
+
boundedText(owner.origin, "origin");
|
|
21721
|
+
boundedText(input.installationId, "installation");
|
|
21722
|
+
boundedText(input.keyFingerprint, "key fingerprint");
|
|
21723
|
+
boundedText(input.subscription?.endpoint, "subscription endpoint");
|
|
21724
|
+
const canonicalEndpoint = canonicalPushEndpoint(input.subscription.endpoint);
|
|
21725
|
+
const normalizedSubscription = {
|
|
21726
|
+
...input.subscription,
|
|
21727
|
+
endpoint: canonicalEndpoint
|
|
21728
|
+
};
|
|
21729
|
+
if (!normalizedSubscription.keys?.p256dh || !normalizedSubscription.keys.auth) throw new Error("invalid subscription keys");
|
|
21730
|
+
if (!this.state.keys || input.keyFingerprint !== this.state.keys.fingerprint) throw new Error("subscription VAPID key fingerprint conflict");
|
|
21731
|
+
const key = {
|
|
21732
|
+
...owner,
|
|
21733
|
+
installationId: input.installationId
|
|
21734
|
+
};
|
|
21735
|
+
const current = this.state.registrations.find((reg) => idOfKey(reg) === idOfKey(key));
|
|
21736
|
+
const sameEndpoint = this.state.registrations.find((reg) => canonicalPushEndpoint(reg.subscription.endpoint) === canonicalEndpoint);
|
|
21737
|
+
if (sameEndpoint && !sameOwner(sameEndpoint, owner)) throw new Error("subscription endpoint is already owned by another device (conflict)");
|
|
21738
|
+
if (sameEndpoint && !current) {
|
|
21739
|
+
if (sameEndpoint.keyFingerprint !== input.keyFingerprint || !sameEndpoint.enabled) throw new Error("canonical subscription recovery conflict");
|
|
21740
|
+
return { registration: clone(sameEndpoint) };
|
|
21741
|
+
}
|
|
21742
|
+
const ownerGeneration = this.ownerGeneration(owner);
|
|
21743
|
+
if (input.ownerGeneration !== ownerGeneration || input.registrationGeneration !== (current?.generation ?? 0)) throw new Error("stale push mutation generation");
|
|
21744
|
+
if (current?.enabled && canonicalPushEndpoint(current.subscription.endpoint) === canonicalEndpoint && current.locale === input.locale && JSON.stringify(current.preferences) === JSON.stringify(input.preferences) && current.keyFingerprint === input.keyFingerprint) return { registration: clone(current) };
|
|
21745
|
+
if (!current && this.state.registrations.filter((reg) => reg.scopeId === owner.scopeId).length >= 256) throw new Error("push registration limit reached for scope");
|
|
21746
|
+
if (!current && this.state.registrations.filter((reg) => sameOwner(reg, owner)).length >= 4) throw new Error("push registration limit reached for device");
|
|
21747
|
+
const generation = current ? current.generation + 1 : ownerGeneration + 1;
|
|
21748
|
+
const registration = {
|
|
21749
|
+
...key,
|
|
21750
|
+
subscription: clone(normalizedSubscription),
|
|
21751
|
+
locale: input.locale,
|
|
21752
|
+
preferences: clone(input.preferences),
|
|
21753
|
+
generation,
|
|
21754
|
+
keyFingerprint: input.keyFingerprint,
|
|
21755
|
+
enabled: true,
|
|
21756
|
+
health: "ready"
|
|
21757
|
+
};
|
|
21758
|
+
let next = {
|
|
21759
|
+
...this.state,
|
|
21760
|
+
registrations: [...this.state.registrations.filter((reg) => idOfKey(reg) !== idOfKey(key)), registration]
|
|
21761
|
+
};
|
|
21762
|
+
next = this.pruneExpired(next);
|
|
21763
|
+
if (next.pending.filter((intent) => intent.key.scopeId === owner.scopeId).length > 256) throw new Error("pending push limit reached for scope");
|
|
21764
|
+
await this.commit(next);
|
|
21765
|
+
return {
|
|
21766
|
+
registration: clone(registration),
|
|
21767
|
+
mutationGeneration: generation
|
|
21768
|
+
};
|
|
21769
|
+
});
|
|
21770
|
+
}
|
|
21771
|
+
async preferences(key, value, guard) {
|
|
21772
|
+
return this.serialize(async () => {
|
|
21773
|
+
this.assertReady();
|
|
21774
|
+
const reg = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21775
|
+
if (!reg || guard.ownerGeneration !== this.ownerGeneration(key) || guard.registrationGeneration !== reg.generation) throw new Error("stale push mutation generation");
|
|
21776
|
+
if (!value || ![
|
|
21777
|
+
"question",
|
|
21778
|
+
"plan",
|
|
21779
|
+
"approval",
|
|
21780
|
+
"response",
|
|
21781
|
+
"goal",
|
|
21782
|
+
"failure"
|
|
21783
|
+
].every((k) => typeof value[k] === "boolean")) throw new Error("invalid push preferences");
|
|
21784
|
+
const updated = {
|
|
21785
|
+
...reg,
|
|
21786
|
+
preferences: clone(value),
|
|
21787
|
+
generation: reg.generation + 1
|
|
21788
|
+
};
|
|
21789
|
+
await this.commit({
|
|
21790
|
+
...this.state,
|
|
21791
|
+
registrations: this.state.registrations.map((row) => idOfKey(row) === idOfKey(key) ? updated : row)
|
|
21792
|
+
});
|
|
21793
|
+
return clone(updated);
|
|
21794
|
+
});
|
|
21795
|
+
}
|
|
21796
|
+
async disable(key, ifGeneration) {
|
|
21797
|
+
await this.serialize(async () => {
|
|
21798
|
+
this.assertReady();
|
|
21799
|
+
const reg = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21800
|
+
if (ifGeneration !== void 0 && reg?.generation !== ifGeneration) return;
|
|
21801
|
+
let next = this.state;
|
|
21802
|
+
if (reg) next = {
|
|
21803
|
+
...next,
|
|
21804
|
+
registrations: next.registrations.map((row) => idOfKey(row) === idOfKey(key) ? {
|
|
21805
|
+
...row,
|
|
21806
|
+
enabled: false,
|
|
21807
|
+
generation: row.generation + 1
|
|
21808
|
+
} : row)
|
|
21809
|
+
};
|
|
21810
|
+
next = this.putOwnerGeneration(next, key, this.ownerGeneration(key) + 1);
|
|
21811
|
+
next = {
|
|
21812
|
+
...next,
|
|
21813
|
+
pending: next.pending.filter((intent) => idOfKey(intent.key) !== idOfKey(key))
|
|
21814
|
+
};
|
|
21815
|
+
await this.commit(next);
|
|
21816
|
+
});
|
|
21817
|
+
}
|
|
21818
|
+
async settleRecipient(key, expectedGeneration, result) {
|
|
21819
|
+
await this.serialize(async () => {
|
|
21820
|
+
this.assertReady();
|
|
21821
|
+
const registration = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21822
|
+
if (!registration || registration.generation !== expectedGeneration) return;
|
|
21823
|
+
if (result === "gone") {
|
|
21824
|
+
const next = {
|
|
21825
|
+
...this.advanceOwnerGenerationFloor(this.state, [registration.generation]),
|
|
21826
|
+
registrations: this.state.registrations.filter((row) => idOfKey(row) !== idOfKey(key)),
|
|
21827
|
+
pending: this.state.pending.filter((row) => idOfKey(row.key) !== idOfKey(key)),
|
|
21828
|
+
receipts: this.state.receipts.filter((row) => idOfKey(row.key) !== idOfKey(key)),
|
|
21829
|
+
references: this.state.references.filter((row) => idOfKey(row.key) !== idOfKey(key))
|
|
21830
|
+
};
|
|
21831
|
+
await this.commit(next);
|
|
21832
|
+
} else {
|
|
21833
|
+
const updated = {
|
|
21834
|
+
...registration,
|
|
21835
|
+
health: "suspended",
|
|
21836
|
+
generation: registration.generation + 1
|
|
21837
|
+
};
|
|
21838
|
+
await this.commit({
|
|
21839
|
+
...this.state,
|
|
21840
|
+
registrations: this.state.registrations.map((row) => idOfKey(row) === idOfKey(key) ? updated : row)
|
|
21841
|
+
});
|
|
21842
|
+
}
|
|
21843
|
+
});
|
|
21844
|
+
}
|
|
21845
|
+
async disableOwner(owner) {
|
|
21846
|
+
await this.serialize(async () => {
|
|
21847
|
+
this.assertReady();
|
|
21848
|
+
let next = this.state;
|
|
21849
|
+
next = {
|
|
21850
|
+
...next,
|
|
21851
|
+
registrations: next.registrations.map((reg) => sameOwner(reg, owner) ? {
|
|
21852
|
+
...reg,
|
|
21853
|
+
enabled: false,
|
|
21854
|
+
generation: reg.generation + 1
|
|
21855
|
+
} : reg),
|
|
21856
|
+
pending: next.pending.filter((intent) => !sameOwner(intent.key, owner))
|
|
21857
|
+
};
|
|
21858
|
+
next = this.putOwnerGeneration(next, owner, this.ownerGeneration(owner) + 1);
|
|
21859
|
+
await this.commit(next);
|
|
21860
|
+
});
|
|
21861
|
+
}
|
|
21862
|
+
async purgeDevices(scopeId, ids) {
|
|
21863
|
+
await this.serialize(async () => {
|
|
21864
|
+
this.assertReady();
|
|
21865
|
+
if (ids.length === 0) return;
|
|
21866
|
+
const selected = new Set(ids);
|
|
21867
|
+
const remove = (key) => key.scopeId === scopeId && selected.has(key.deviceId);
|
|
21868
|
+
const removedRegistrationGenerations = this.state.registrations.filter((reg) => remove(reg)).map((reg) => reg.generation);
|
|
21869
|
+
const withFloor = this.advanceOwnerGenerationFloor(this.state, removedRegistrationGenerations);
|
|
21870
|
+
const next = {
|
|
21871
|
+
...withFloor,
|
|
21872
|
+
ownerGenerations: withFloor.ownerGenerations.filter((row) => !remove(row)),
|
|
21873
|
+
registrations: withFloor.registrations.filter((reg) => !remove(reg)),
|
|
21874
|
+
pending: withFloor.pending.filter((intent) => !remove(intent.key)),
|
|
21875
|
+
receipts: withFloor.receipts.filter((row) => !remove(row.key)),
|
|
21876
|
+
references: withFloor.references.filter((row) => !remove(row.key))
|
|
21877
|
+
};
|
|
21878
|
+
await this.commit(next);
|
|
21879
|
+
});
|
|
21880
|
+
}
|
|
21881
|
+
async resetKeys() {
|
|
21882
|
+
await this.serialize(async () => {
|
|
21883
|
+
this.assertReady();
|
|
21884
|
+
const withFloor = this.advanceOwnerGenerationFloor(this.state, this.state.registrations.map((reg) => reg.generation));
|
|
21885
|
+
const keys = this.createKeys();
|
|
21886
|
+
const nextKeys = {
|
|
21887
|
+
...keys,
|
|
21888
|
+
fingerprint: fingerprint(keys)
|
|
21889
|
+
};
|
|
21890
|
+
const next = {
|
|
21891
|
+
...withFloor,
|
|
21892
|
+
keys: nextKeys,
|
|
21893
|
+
ownerGenerations: [],
|
|
21894
|
+
registrations: [],
|
|
21895
|
+
pending: [],
|
|
21896
|
+
receipts: [],
|
|
21897
|
+
references: []
|
|
21898
|
+
};
|
|
21899
|
+
await this.commit(next);
|
|
21900
|
+
});
|
|
21901
|
+
}
|
|
21902
|
+
async close() {
|
|
21903
|
+
await this.queue;
|
|
21904
|
+
this.closed = true;
|
|
21905
|
+
}
|
|
21906
|
+
pruneExpired(snapshot) {
|
|
21907
|
+
const now = Date.now();
|
|
21908
|
+
const pending = snapshot.pending.filter((item) => item.source.expiresAt > now);
|
|
21909
|
+
return {
|
|
21910
|
+
...snapshot,
|
|
21911
|
+
pending,
|
|
21912
|
+
receipts: snapshot.receipts.filter((item) => item.expiresAt > now),
|
|
21913
|
+
references: snapshot.references.filter((item) => item.expiresAt > now)
|
|
21914
|
+
};
|
|
21915
|
+
}
|
|
21916
|
+
};
|
|
21917
|
+
//#endregion
|
|
21918
|
+
//#region src/mobile-push/payload.ts
|
|
21919
|
+
const messages = {
|
|
21920
|
+
en: {
|
|
21921
|
+
question: ["Input requested", "A response needs your attention."],
|
|
21922
|
+
plan: ["Plan review requested", "A plan review needs your attention."],
|
|
21923
|
+
approval: ["Permission requested", "A permission request needs your attention."],
|
|
21924
|
+
response: ["Response ready", "A response is ready to view."],
|
|
21925
|
+
goal: ["Goal update", "A goal has an important update."],
|
|
21926
|
+
failure: ["Attention needed", "A run needs your attention."],
|
|
21927
|
+
test: ["Test notification", "Your notification settings are working."]
|
|
21928
|
+
},
|
|
21929
|
+
"zh-CN": {
|
|
21930
|
+
question: ["需要输入", "有一项回复需要您关注。"],
|
|
21931
|
+
plan: ["需要审核计划", "有一项计划审核需要您关注。"],
|
|
21932
|
+
approval: ["需要授权", "有一项授权请求需要您关注。"],
|
|
21933
|
+
response: ["回复已就绪", "有一项回复已准备好查看。"],
|
|
21934
|
+
goal: ["目标更新", "有一项目标状态更新。"],
|
|
21935
|
+
failure: ["需要关注", "有一项运行需要您关注。"],
|
|
21936
|
+
test: ["测试通知", "通知设置已正常工作。"]
|
|
21937
|
+
},
|
|
21938
|
+
it: {
|
|
21939
|
+
question: ["Input richiesto", "Una risposta richiede la tua attenzione."],
|
|
21940
|
+
plan: ["Revisione del piano richiesta", "Una revisione del piano richiede la tua attenzione."],
|
|
21941
|
+
approval: ["Autorizzazione richiesta", "Una richiesta di autorizzazione richiede la tua attenzione."],
|
|
21942
|
+
response: ["Risposta pronta", "Una risposta è pronta da visualizzare."],
|
|
21943
|
+
goal: ["Aggiornamento obiettivo", "Un obiettivo ha un aggiornamento importante."],
|
|
21944
|
+
failure: ["Attenzione richiesta", "Un’esecuzione richiede la tua attenzione."],
|
|
21945
|
+
test: ["Notifica di prova", "Le impostazioni delle notifiche funzionano."]
|
|
21946
|
+
}
|
|
21947
|
+
};
|
|
21948
|
+
function validateNavigate(navigate, category) {
|
|
21949
|
+
if (category === "test" && navigate === "/") return;
|
|
21950
|
+
if (typeof navigate !== "string" || !/^\/\?mobile-push=[A-Za-z0-9_-]{1,256}$/u.test(navigate)) throw new TypeError("push_navigation_invalid");
|
|
21951
|
+
}
|
|
21952
|
+
/** Build Apple's declarative Web Push schema with generic, locale-owned text. */
|
|
21953
|
+
function buildDeclarativePayload(category, locale, navigate, tag) {
|
|
21954
|
+
validateNavigate(navigate, category);
|
|
21955
|
+
if (!(locale in messages) || !(category in messages[locale]) || typeof tag !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/u.test(tag)) throw new TypeError("push_payload_invalid");
|
|
21956
|
+
const [title, body] = messages[locale][category];
|
|
21957
|
+
const payload = JSON.stringify({
|
|
21958
|
+
web_push: 8030,
|
|
21959
|
+
notification: {
|
|
21960
|
+
title,
|
|
21961
|
+
body,
|
|
21962
|
+
lang: locale,
|
|
21963
|
+
navigate,
|
|
21964
|
+
tag
|
|
21965
|
+
}
|
|
21966
|
+
});
|
|
21967
|
+
if (Buffer.byteLength(payload, "utf8") > 3072) throw new TypeError("push_payload_too_large");
|
|
21968
|
+
return payload;
|
|
21969
|
+
}
|
|
21970
|
+
//#endregion
|
|
21971
|
+
//#region src/mobile-push/retention.ts
|
|
21972
|
+
/** Pure defer-membership retention helpers shared by delivery maintenance. */
|
|
21973
|
+
/** Removes deferred-recheck entries whose owning pending intent no longer exists; returns how many were removed. */
|
|
21974
|
+
function pruneOrphanedDeferrals(deferred, pending) {
|
|
21975
|
+
const live = new Set(pending.map((item) => item.id));
|
|
21976
|
+
let removed = 0;
|
|
21977
|
+
for (const id of deferred.keys()) {
|
|
21978
|
+
if (live.has(id)) continue;
|
|
21979
|
+
deferred.delete(id);
|
|
21980
|
+
removed += 1;
|
|
21981
|
+
}
|
|
21982
|
+
return removed;
|
|
21983
|
+
}
|
|
21984
|
+
//#endregion
|
|
21985
|
+
//#region src/mobile-push/sender.ts
|
|
21986
|
+
const MAX_RESPONSE_BYTES = 8192;
|
|
21987
|
+
const MAX_TTL_SECONDS = 1800;
|
|
21988
|
+
const CONTENT_ENCODING = "aes128gcm";
|
|
21989
|
+
function isObject(value) {
|
|
21990
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21991
|
+
}
|
|
21992
|
+
function validatePayload(payload) {
|
|
21993
|
+
if (typeof payload !== "string" || Buffer.byteLength(payload, "utf8") > 3072) return false;
|
|
21994
|
+
try {
|
|
21995
|
+
const parsed = JSON.parse(payload);
|
|
21996
|
+
if (!isObject(parsed) || parsed.web_push !== 8030 || !isObject(parsed.notification) || Object.keys(parsed).length !== 2 || Object.keys(parsed.notification).length !== 5) return false;
|
|
21997
|
+
const notification = parsed.notification;
|
|
21998
|
+
return typeof notification.title === "string" && notification.title.length > 0 && typeof notification.body === "string" && notification.body.length > 0 && (notification.lang === "en" || notification.lang === "zh-CN" || notification.lang === "it") && typeof notification.navigate === "string" && (notification.navigate === "/" || /^\/\?mobile-push=[A-Za-z0-9_-]{1,256}$/u.test(notification.navigate)) && typeof notification.tag === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(notification.tag);
|
|
21999
|
+
} catch {
|
|
22000
|
+
return false;
|
|
22001
|
+
}
|
|
22002
|
+
}
|
|
22003
|
+
function validSubject(subject, origin) {
|
|
22004
|
+
if (typeof subject !== "string") return void 0;
|
|
22005
|
+
const candidate = subject.trim() === "" ? `${origin}/` : subject;
|
|
22006
|
+
let url;
|
|
22007
|
+
try {
|
|
22008
|
+
url = new URL(candidate);
|
|
22009
|
+
} catch {
|
|
22010
|
+
return;
|
|
22011
|
+
}
|
|
22012
|
+
if (url.protocol === "mailto:") return /^mailto:[^\s/?#@]+@[^\s/?#@]+$/iu.test(candidate) ? candidate : void 0;
|
|
22013
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") return void 0;
|
|
22014
|
+
const hostname = url.hostname.toLowerCase();
|
|
22015
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname)) return void 0;
|
|
22016
|
+
return candidate;
|
|
22017
|
+
}
|
|
22018
|
+
function canonicalOrigin(origin) {
|
|
22019
|
+
try {
|
|
22020
|
+
const url = new URL(origin);
|
|
22021
|
+
if (url.protocol !== "https:" || url.origin !== origin || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") return void 0;
|
|
22022
|
+
return url.origin;
|
|
22023
|
+
} catch {
|
|
22024
|
+
return;
|
|
22025
|
+
}
|
|
22026
|
+
}
|
|
22027
|
+
/**
|
|
22028
|
+
* WebKit's declarative push parser resolves `navigate` without any base URL, so the encrypted wire
|
|
22029
|
+
* payload must carry an absolute URL. The already-validated relative template is resolved against
|
|
22030
|
+
* the canonical registration origin and the serialized copy is budget-checked again because the
|
|
22031
|
+
* origin adds bytes the payload template never counted.
|
|
22032
|
+
*/
|
|
22033
|
+
function bindNavigationOrigin(payload, origin) {
|
|
22034
|
+
let parsed;
|
|
22035
|
+
try {
|
|
22036
|
+
parsed = JSON.parse(payload);
|
|
22037
|
+
} catch {
|
|
22038
|
+
return;
|
|
22039
|
+
}
|
|
22040
|
+
if (typeof parsed?.notification?.navigate !== "string") return void 0;
|
|
22041
|
+
let resolved;
|
|
22042
|
+
try {
|
|
22043
|
+
resolved = new URL(parsed.notification.navigate, origin);
|
|
22044
|
+
} catch {
|
|
22045
|
+
return;
|
|
22046
|
+
}
|
|
22047
|
+
if (resolved.origin !== origin) return void 0;
|
|
22048
|
+
const bound = JSON.stringify({
|
|
22049
|
+
...parsed,
|
|
22050
|
+
notification: {
|
|
22051
|
+
...parsed.notification,
|
|
22052
|
+
navigate: resolved.href
|
|
22053
|
+
}
|
|
22054
|
+
});
|
|
22055
|
+
if (Buffer.byteLength(bound, "utf8") > 3072) return void 0;
|
|
22056
|
+
return bound;
|
|
22057
|
+
}
|
|
22058
|
+
function retryAfterMs(value, now) {
|
|
22059
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
22060
|
+
if (!raw) return void 0;
|
|
22061
|
+
if (/^\d{1,10}$/u.test(raw)) return Number(raw) * 1e3;
|
|
22062
|
+
const date = Date.parse(raw);
|
|
22063
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : void 0;
|
|
22064
|
+
}
|
|
22065
|
+
function safeStatus(statusCode, retryAfter, ttlSeconds) {
|
|
22066
|
+
if (statusCode >= 200 && statusCode < 300) return { kind: "accepted" };
|
|
22067
|
+
if (statusCode === 404 || statusCode === 410) return { kind: "gone" };
|
|
22068
|
+
if (statusCode === 401 || statusCode === 403) return { kind: "suspended" };
|
|
22069
|
+
if (statusCode === 429) {
|
|
22070
|
+
if (retryAfter !== void 0 && retryAfter > ttlSeconds * 1e3) return { kind: "permanent" };
|
|
22071
|
+
return retryAfter === void 0 ? { kind: "retry" } : {
|
|
22072
|
+
kind: "retry",
|
|
22073
|
+
retryAfterMs: retryAfter
|
|
22074
|
+
};
|
|
22075
|
+
}
|
|
22076
|
+
if (statusCode >= 500 && statusCode < 600) return { kind: "retry" };
|
|
22077
|
+
return { kind: "permanent" };
|
|
22078
|
+
}
|
|
22079
|
+
function abortable(promise, signal) {
|
|
22080
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("push_aborted"));
|
|
22081
|
+
return new Promise((resolve, reject) => {
|
|
22082
|
+
const abort = () => reject(/* @__PURE__ */ new Error("push_aborted"));
|
|
22083
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
22084
|
+
promise.then((value) => {
|
|
22085
|
+
signal.removeEventListener("abort", abort);
|
|
22086
|
+
resolve(value);
|
|
22087
|
+
}, (error) => {
|
|
22088
|
+
signal.removeEventListener("abort", abort);
|
|
22089
|
+
reject(error);
|
|
22090
|
+
});
|
|
22091
|
+
});
|
|
22092
|
+
}
|
|
22093
|
+
function responseFromRequest(requestFactory, url, options, body, signal) {
|
|
22094
|
+
return new Promise((resolve, reject) => {
|
|
22095
|
+
let request;
|
|
22096
|
+
let settled = false;
|
|
22097
|
+
const finish = (result, error) => {
|
|
22098
|
+
if (settled) return;
|
|
22099
|
+
settled = true;
|
|
22100
|
+
signal.removeEventListener("abort", abortRequest);
|
|
22101
|
+
if (error) reject(error);
|
|
22102
|
+
else if (result) resolve(result);
|
|
22103
|
+
};
|
|
22104
|
+
const abortRequest = () => {
|
|
22105
|
+
request?.destroy(/* @__PURE__ */ new Error("push_aborted"));
|
|
22106
|
+
finish(void 0, /* @__PURE__ */ new Error("push_aborted"));
|
|
22107
|
+
};
|
|
22108
|
+
try {
|
|
22109
|
+
request = requestFactory(url, options);
|
|
22110
|
+
signal.addEventListener("abort", abortRequest, { once: true });
|
|
22111
|
+
request.once("error", () => finish(void 0, /* @__PURE__ */ new Error("push_transport_failed")));
|
|
22112
|
+
request.once("response", (response) => {
|
|
22113
|
+
const statusCode = response.statusCode;
|
|
22114
|
+
if (typeof statusCode !== "number") {
|
|
22115
|
+
response.destroy();
|
|
22116
|
+
request.destroy();
|
|
22117
|
+
finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22118
|
+
return;
|
|
22119
|
+
}
|
|
22120
|
+
if (statusCode >= 300 && statusCode < 400) {
|
|
22121
|
+
finish({
|
|
22122
|
+
statusCode,
|
|
22123
|
+
headers: response.headers
|
|
22124
|
+
});
|
|
22125
|
+
response.destroy();
|
|
22126
|
+
request.destroy();
|
|
22127
|
+
return;
|
|
22128
|
+
}
|
|
22129
|
+
let bytes = 0;
|
|
22130
|
+
let oversized = false;
|
|
22131
|
+
response.on("data", (chunk) => {
|
|
22132
|
+
bytes += Buffer.byteLength(chunk);
|
|
22133
|
+
if (bytes > MAX_RESPONSE_BYTES && !oversized) {
|
|
22134
|
+
oversized = true;
|
|
22135
|
+
finish({
|
|
22136
|
+
statusCode,
|
|
22137
|
+
headers: response.headers
|
|
22138
|
+
});
|
|
22139
|
+
response.destroy();
|
|
22140
|
+
request.destroy();
|
|
22141
|
+
}
|
|
22142
|
+
});
|
|
22143
|
+
response.once("error", () => {
|
|
22144
|
+
if (!oversized) finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22145
|
+
});
|
|
22146
|
+
response.once("end", () => finish({
|
|
22147
|
+
statusCode,
|
|
22148
|
+
headers: response.headers
|
|
22149
|
+
}));
|
|
22150
|
+
});
|
|
22151
|
+
request.end(body);
|
|
22152
|
+
} catch {
|
|
22153
|
+
finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22154
|
+
}
|
|
22155
|
+
});
|
|
22156
|
+
}
|
|
22157
|
+
function makePinnedLookup(hostname, address) {
|
|
22158
|
+
return ((queryHostname, options, callback) => {
|
|
22159
|
+
if (queryHostname.toLowerCase() !== hostname.toLowerCase()) {
|
|
22160
|
+
callback(/* @__PURE__ */ new Error("push_dns_pin_mismatch"));
|
|
22161
|
+
return;
|
|
22162
|
+
}
|
|
22163
|
+
if (isObject(options) && options.all === true) callback(null, [{
|
|
22164
|
+
address: address.address,
|
|
22165
|
+
family: address.family
|
|
22166
|
+
}]);
|
|
22167
|
+
else callback(null, address.address, address.family);
|
|
22168
|
+
});
|
|
22169
|
+
}
|
|
22170
|
+
function requestOptions(url, details, address) {
|
|
22171
|
+
return {
|
|
22172
|
+
method: details.method,
|
|
22173
|
+
headers: details.headers,
|
|
22174
|
+
agent: false,
|
|
22175
|
+
servername: url.hostname,
|
|
22176
|
+
rejectUnauthorized: true,
|
|
22177
|
+
lookup: makePinnedLookup(url.hostname, address),
|
|
22178
|
+
timeout: ATTEMPT_TIMEOUT_MS
|
|
22179
|
+
};
|
|
22180
|
+
}
|
|
22181
|
+
/** Generate one persistent installation VAPID key pair via the maintained library. */
|
|
22182
|
+
function createVapidKeys() {
|
|
22183
|
+
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
|
22184
|
+
return Object.freeze({
|
|
22185
|
+
publicKey,
|
|
22186
|
+
privateKey
|
|
22187
|
+
});
|
|
22188
|
+
}
|
|
22189
|
+
/** Encrypted, no-redirect HTTPS sender with DNS pinning and one absolute wall-clock budget. */
|
|
22190
|
+
var PushSender = class {
|
|
22191
|
+
resolver;
|
|
22192
|
+
requestFactory;
|
|
22193
|
+
budgetMs;
|
|
22194
|
+
constructor(dependencies = {}) {
|
|
22195
|
+
this.resolver = dependencies.resolve ?? (async (hostname) => {
|
|
22196
|
+
return (await lookup(hostname, {
|
|
22197
|
+
all: true,
|
|
22198
|
+
verbatim: true
|
|
22199
|
+
})).filter((item) => item.family === 4 || item.family === 6);
|
|
22200
|
+
});
|
|
22201
|
+
this.requestFactory = dependencies.request ?? https.request.bind(https);
|
|
22202
|
+
this.budgetMs = Math.max(1, Math.min(ATTEMPT_TIMEOUT_MS, dependencies.attemptTimeoutMs ?? 1e4));
|
|
22203
|
+
}
|
|
22204
|
+
async send(input) {
|
|
22205
|
+
const started = performance.now();
|
|
22206
|
+
const controller = new AbortController();
|
|
22207
|
+
const callerAbort = () => controller.abort();
|
|
22208
|
+
if (input.signal.aborted) controller.abort();
|
|
22209
|
+
else input.signal.addEventListener("abort", callerAbort, { once: true });
|
|
22210
|
+
const deadline = setTimeout(() => controller.abort(), this.budgetMs);
|
|
22211
|
+
try {
|
|
22212
|
+
if (controller.signal.aborted) return { kind: "retry" };
|
|
22213
|
+
if (!Number.isSafeInteger(input.ttlSeconds) || input.ttlSeconds < 1 || input.ttlSeconds > MAX_TTL_SECONDS || !validatePayload(input.payload)) return { kind: "permanent" };
|
|
22214
|
+
const origin = canonicalOrigin(input.registration.origin);
|
|
22215
|
+
if (!origin) return { kind: "permanent" };
|
|
22216
|
+
const wirePayload = bindNavigationOrigin(input.payload, origin);
|
|
22217
|
+
if (!wirePayload) return { kind: "permanent" };
|
|
22218
|
+
const subject = validSubject(input.subject, origin);
|
|
22219
|
+
if (!subject) return { kind: "permanent" };
|
|
22220
|
+
let subscription;
|
|
22221
|
+
try {
|
|
22222
|
+
subscription = validatePushSubscription(input.registration.subscription);
|
|
22223
|
+
} catch {
|
|
22224
|
+
return { kind: "permanent" };
|
|
22225
|
+
}
|
|
22226
|
+
const resolved = await abortable(resolveAppleEndpoint(subscription.endpoint, this.resolver), controller.signal);
|
|
22227
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22228
|
+
const options = {
|
|
22229
|
+
contentEncoding: CONTENT_ENCODING,
|
|
22230
|
+
vapidDetails: {
|
|
22231
|
+
subject,
|
|
22232
|
+
publicKey: input.keys.publicKey,
|
|
22233
|
+
privateKey: input.keys.privateKey
|
|
22234
|
+
},
|
|
22235
|
+
TTL: input.ttlSeconds,
|
|
22236
|
+
timeout: ATTEMPT_TIMEOUT_MS
|
|
22237
|
+
};
|
|
22238
|
+
let details;
|
|
22239
|
+
try {
|
|
22240
|
+
details = webPush.generateRequestDetails(subscription, wirePayload, options);
|
|
22241
|
+
} catch {
|
|
22242
|
+
return { kind: "permanent" };
|
|
22243
|
+
}
|
|
22244
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22245
|
+
const requestConfig = requestOptions(resolved.url, details, resolved.addresses[0]);
|
|
22246
|
+
const beforeRequest = input.beforeRequest;
|
|
22247
|
+
if (beforeRequest !== void 0) {
|
|
22248
|
+
if (typeof beforeRequest !== "function") return { kind: "cancelled" };
|
|
22249
|
+
let permitted;
|
|
22250
|
+
try {
|
|
22251
|
+
permitted = await abortable(Promise.resolve().then(() => beforeRequest(controller.signal)), controller.signal);
|
|
22252
|
+
} catch {
|
|
22253
|
+
return { kind: "cancelled" };
|
|
22254
|
+
}
|
|
22255
|
+
if (permitted !== true || controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "cancelled" };
|
|
22256
|
+
}
|
|
22257
|
+
const response = await abortable(responseFromRequest(this.requestFactory, resolved.url, requestConfig, details.body ?? Buffer.alloc(0), controller.signal), controller.signal);
|
|
22258
|
+
if (controller.signal.aborted || performance.now() - started > this.budgetMs) return { kind: "retry" };
|
|
22259
|
+
const waitMs = retryAfterMs(response.headers["retry-after"], Date.now());
|
|
22260
|
+
return safeStatus(response.statusCode, waitMs, input.ttlSeconds);
|
|
22261
|
+
} catch (error) {
|
|
22262
|
+
if (error instanceof TypeError && error.message === "push_endpoint_address_rejected") return { kind: "permanent" };
|
|
22263
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22264
|
+
return { kind: "retry" };
|
|
22265
|
+
} finally {
|
|
22266
|
+
clearTimeout(deadline);
|
|
22267
|
+
input.signal.removeEventListener("abort", callerAbort);
|
|
22268
|
+
}
|
|
22269
|
+
}
|
|
22270
|
+
};
|
|
22271
|
+
//#endregion
|
|
22272
|
+
//#region src/mobile-push/delivery.ts
|
|
22273
|
+
const RECONCILE_INTERVAL_MS = 3e4;
|
|
22274
|
+
const RETRY_BACKOFF_MS = [1e3, 5e3];
|
|
22275
|
+
const MAX_TEST_QUEUE = 64;
|
|
22276
|
+
const TEST_RATE_LIMIT_MS = 6e4;
|
|
22277
|
+
const UNVERIFIABLE_RECHECK_MS = 3e4;
|
|
22278
|
+
function ownerId(value) {
|
|
22279
|
+
return JSON.stringify([
|
|
22280
|
+
value.scopeId,
|
|
22281
|
+
value.deviceId,
|
|
22282
|
+
value.origin
|
|
22283
|
+
]);
|
|
22284
|
+
}
|
|
22285
|
+
function keyId(value) {
|
|
22286
|
+
return JSON.stringify([
|
|
22287
|
+
value.scopeId,
|
|
22288
|
+
value.deviceId,
|
|
22289
|
+
value.origin,
|
|
22290
|
+
value.installationId
|
|
22291
|
+
]);
|
|
22292
|
+
}
|
|
22293
|
+
function sameKey(a, b) {
|
|
22294
|
+
return keyId(a) === keyId(b);
|
|
22295
|
+
}
|
|
22296
|
+
function isPreferenceEnabled(preferences, category) {
|
|
22297
|
+
return preferences[category] === true;
|
|
22298
|
+
}
|
|
22299
|
+
function pendingHas(snapshot, intent) {
|
|
22300
|
+
return snapshot.pending.some((item) => item.id === intent.id && sameKey(item.key, intent.key));
|
|
22301
|
+
}
|
|
22302
|
+
function recipientCurrent(snapshot, intent) {
|
|
22303
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, intent.key));
|
|
22304
|
+
return !!registration && registration.enabled && registration.health === "ready" && registration.generation === intent.recipientGeneration && registration.keyFingerprint === snapshot.keys?.fingerprint && isPreferenceEnabled(registration.preferences, intent.source.category);
|
|
22305
|
+
}
|
|
22306
|
+
function boundedSnapshot(snapshot, now) {
|
|
22307
|
+
return {
|
|
22308
|
+
...snapshot,
|
|
22309
|
+
pending: snapshot.pending.filter((item) => item.source.expiresAt > now),
|
|
22310
|
+
receipts: snapshot.receipts.filter((item) => item.expiresAt > now),
|
|
22311
|
+
references: snapshot.references.filter((item) => item.expiresAt > now)
|
|
22312
|
+
};
|
|
22313
|
+
}
|
|
22314
|
+
/** Coordinates bounded, consent-checked delivery while all durable writes remain registry-owned. */
|
|
22315
|
+
var PushDelivery = class {
|
|
22316
|
+
#host;
|
|
22317
|
+
#registry;
|
|
22318
|
+
#sender;
|
|
22319
|
+
#now;
|
|
22320
|
+
#origins = /* @__PURE__ */ new Map();
|
|
22321
|
+
#inFlight = /* @__PURE__ */ new Map();
|
|
22322
|
+
#inFlightIds = /* @__PURE__ */ new Set();
|
|
22323
|
+
#tasks = /* @__PURE__ */ new Set();
|
|
22324
|
+
#tests = [];
|
|
22325
|
+
#testAttempts = /* @__PURE__ */ new Map();
|
|
22326
|
+
#deferredUntil = /* @__PURE__ */ new Map();
|
|
22327
|
+
#purgeTasks = /* @__PURE__ */ new Map();
|
|
22328
|
+
#writeQueue = Promise.resolve();
|
|
22329
|
+
#unsubscribe;
|
|
22330
|
+
#interval;
|
|
22331
|
+
#wakeTimer;
|
|
22332
|
+
#started = false;
|
|
22333
|
+
#closed = false;
|
|
22334
|
+
#admitting = true;
|
|
22335
|
+
#pumping = false;
|
|
22336
|
+
#active = 0;
|
|
22337
|
+
constructor(options) {
|
|
22338
|
+
this.#host = options.host;
|
|
22339
|
+
this.#registry = options.registry;
|
|
22340
|
+
this.#sender = options.sender;
|
|
22341
|
+
this.#now = options.now ?? Date.now;
|
|
22342
|
+
}
|
|
22343
|
+
async start() {
|
|
22344
|
+
if (this.#closed) throw new Error("push delivery is closed");
|
|
22345
|
+
if (this.#started) return;
|
|
22346
|
+
this.#started = true;
|
|
22347
|
+
this.#unsubscribe = this.#host.subscribe((source) => this.observe(source), (sessionId) => {
|
|
22348
|
+
this.#reconcileSession(sessionId).catch(() => void 0);
|
|
22349
|
+
});
|
|
22350
|
+
await this.#reconcileKnownScopes();
|
|
22351
|
+
await this.#reconcilePending();
|
|
22352
|
+
if (this.#closed) return;
|
|
22353
|
+
this.#interval = setInterval(() => {
|
|
22354
|
+
this.#periodicReconcile().catch(() => void 0);
|
|
22355
|
+
}, RECONCILE_INTERVAL_MS);
|
|
22356
|
+
this.#kick();
|
|
22357
|
+
}
|
|
22358
|
+
/** Nonblocking host-observer boundary: admission and durable outbox writes happen later. */
|
|
22359
|
+
observe(source) {
|
|
22360
|
+
if (!this.#admitting || !this.#started || this.#closed) return;
|
|
22361
|
+
this.#admit(source).catch(() => {});
|
|
22362
|
+
}
|
|
22363
|
+
attachOrigin(input) {
|
|
22364
|
+
if (this.#closed) return () => void 0;
|
|
22365
|
+
const id = this.#originId(input);
|
|
22366
|
+
const previous = this.#origins.get(id);
|
|
22367
|
+
if (previous?.active) {
|
|
22368
|
+
previous.active = false;
|
|
22369
|
+
previous.generation += 1;
|
|
22370
|
+
for (const work of this.#inFlight.values()) if (work.origin === previous) work.controller.abort();
|
|
22371
|
+
}
|
|
22372
|
+
const origin = {
|
|
22373
|
+
...input,
|
|
22374
|
+
active: true,
|
|
22375
|
+
generation: (previous?.generation ?? 0) + 1
|
|
22376
|
+
};
|
|
22377
|
+
this.#origins.set(id, origin);
|
|
22378
|
+
this.#clearOriginDeferrals(origin);
|
|
22379
|
+
this.#kick();
|
|
22380
|
+
return () => {
|
|
22381
|
+
if (!origin.active || this.#origins.get(id) !== origin) return;
|
|
22382
|
+
origin.active = false;
|
|
22383
|
+
origin.generation += 1;
|
|
22384
|
+
for (const work of this.#inFlight.values()) if (work.origin === origin) work.controller.abort();
|
|
22385
|
+
};
|
|
22386
|
+
}
|
|
22387
|
+
async invalidateDevices(scopeId, ids) {
|
|
22388
|
+
if (ids.length === 0 || this.#closed) return;
|
|
22389
|
+
const selected = new Set(ids);
|
|
22390
|
+
for (const work of this.#inFlight.values()) if (work.key.scopeId === scopeId && selected.has(work.key.deviceId)) work.controller.abort();
|
|
22391
|
+
await this.#purgeDevices(scopeId, ids);
|
|
22392
|
+
this.#kick();
|
|
22393
|
+
}
|
|
22394
|
+
async test(key) {
|
|
22395
|
+
if (!this.#started || this.#closed || this.#tests.length >= MAX_TEST_QUEUE) throw new Error("push_test_unavailable");
|
|
22396
|
+
const identity = keyId(key);
|
|
22397
|
+
const now = this.#now();
|
|
22398
|
+
const last = this.#testAttempts.get(identity);
|
|
22399
|
+
if (last !== void 0 && now - last < TEST_RATE_LIMIT_MS) throw new Error("push_test_rate_limited");
|
|
22400
|
+
this.#testAttempts.set(identity, now);
|
|
22401
|
+
while (this.#testAttempts.size > 512) this.#testAttempts.delete(this.#testAttempts.keys().next().value);
|
|
22402
|
+
return new Promise((resolve, reject) => {
|
|
22403
|
+
this.#tests.push({
|
|
22404
|
+
kind: "test",
|
|
22405
|
+
key: { ...key },
|
|
22406
|
+
resolve,
|
|
22407
|
+
reject
|
|
22408
|
+
});
|
|
22409
|
+
this.#kick();
|
|
22410
|
+
});
|
|
22411
|
+
}
|
|
22412
|
+
async resolve(owner, reference) {
|
|
22413
|
+
if (this.#closed || !reference) return void 0;
|
|
22414
|
+
let snapshot;
|
|
22415
|
+
try {
|
|
22416
|
+
snapshot = this.#registry.snapshot();
|
|
22417
|
+
} catch {
|
|
22418
|
+
return;
|
|
22419
|
+
}
|
|
22420
|
+
const row = snapshot.references.find((item) => item.reference === reference && ownerId(item.key) === ownerId(owner) && item.expiresAt > this.#now());
|
|
22421
|
+
if (!row || row.source.expiresAt <= this.#now()) return void 0;
|
|
22422
|
+
const origin = this.#originFor(row.key);
|
|
22423
|
+
if (!origin?.active || await this.#guardKey(row.key, row.source, origin, true) !== "allowed") return void 0;
|
|
22424
|
+
try {
|
|
22425
|
+
if (!await this.#host.sessionExists(row.source.targetSessionId)) return void 0;
|
|
22426
|
+
return await this.#host.resolveTarget(row.source);
|
|
22427
|
+
} catch {
|
|
22428
|
+
return;
|
|
22429
|
+
}
|
|
22430
|
+
}
|
|
22431
|
+
async close() {
|
|
22432
|
+
if (this.#closed) return;
|
|
22433
|
+
this.#admitting = false;
|
|
22434
|
+
this.#closed = true;
|
|
22435
|
+
this.#unsubscribe?.();
|
|
22436
|
+
this.#unsubscribe = void 0;
|
|
22437
|
+
if (this.#interval) clearInterval(this.#interval);
|
|
22438
|
+
if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
|
|
22439
|
+
this.#interval = void 0;
|
|
22440
|
+
this.#wakeTimer = void 0;
|
|
22441
|
+
for (const work of this.#inFlight.values()) work.controller.abort();
|
|
22442
|
+
for (const item of this.#tests.splice(0)) item.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22443
|
+
await Promise.allSettled([...this.#tasks]);
|
|
22444
|
+
await this.#writeQueue;
|
|
22445
|
+
await Promise.allSettled([...new Set(this.#purgeTasks.values())]);
|
|
22446
|
+
this.#deferredUntil.clear();
|
|
22447
|
+
for (const origin of this.#origins.values()) origin.active = false;
|
|
22448
|
+
this.#origins.clear();
|
|
22449
|
+
}
|
|
22450
|
+
async #admit(source) {
|
|
22451
|
+
if (!this.#admitting || source.expiresAt <= this.#now()) return;
|
|
22452
|
+
let eligible = false;
|
|
22453
|
+
try {
|
|
22454
|
+
eligible = this.#host.available && await this.#host.eligible(source);
|
|
22455
|
+
} catch {
|
|
22456
|
+
eligible = false;
|
|
22457
|
+
}
|
|
22458
|
+
if (!eligible || source.expiresAt <= this.#now()) return;
|
|
22459
|
+
for (const origin of this.#origins.values()) {
|
|
22460
|
+
if (!origin.active || this.#closed) continue;
|
|
22461
|
+
const devices = await this.#readAuthorized(origin);
|
|
22462
|
+
if (!devices || !this.#admitting || this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin) continue;
|
|
22463
|
+
let snapshot;
|
|
22464
|
+
try {
|
|
22465
|
+
snapshot = this.#registry.snapshot();
|
|
22466
|
+
} catch {
|
|
22467
|
+
return;
|
|
22468
|
+
}
|
|
22469
|
+
const registrations = snapshot.registrations.filter((reg) => reg.scopeId === origin.scopeId && reg.origin === origin.origin && devices.has(reg.deviceId) && reg.enabled && reg.health === "ready" && isPreferenceEnabled(reg.preferences, source.category) && snapshot.keys?.fingerprint === reg.keyFingerprint);
|
|
22470
|
+
if (registrations.length === 0) continue;
|
|
22471
|
+
await this.#write((current) => {
|
|
22472
|
+
const clean = boundedSnapshot(current, this.#now());
|
|
22473
|
+
if (!this.#admitting || this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin) return clean;
|
|
22474
|
+
const pending = [...clean.pending];
|
|
22475
|
+
for (const registration of registrations) {
|
|
22476
|
+
const key = {
|
|
22477
|
+
scopeId: registration.scopeId,
|
|
22478
|
+
deviceId: registration.deviceId,
|
|
22479
|
+
origin: registration.origin,
|
|
22480
|
+
installationId: registration.installationId
|
|
22481
|
+
};
|
|
22482
|
+
const currentRegistration = clean.registrations.find((row) => sameKey(row, key));
|
|
22483
|
+
if (!currentRegistration || !currentRegistration.enabled || currentRegistration.health !== "ready" || currentRegistration.generation !== registration.generation || currentRegistration.keyFingerprint !== registration.keyFingerprint || clean.keys?.fingerprint !== currentRegistration.keyFingerprint || !isPreferenceEnabled(currentRegistration.preferences, source.category)) continue;
|
|
22484
|
+
if (clean.receipts.some((item) => item.sourceId === source.sourceId && sameKey(item.key, key))) continue;
|
|
22485
|
+
if (pending.some((item) => item.source.sourceId === source.sourceId && sameKey(item.key, key))) continue;
|
|
22486
|
+
if (pending.filter((item) => item.key.scopeId === origin.scopeId).length >= 256) continue;
|
|
22487
|
+
pending.push({
|
|
22488
|
+
id: randomUUID(),
|
|
22489
|
+
key,
|
|
22490
|
+
source: structuredClone(source),
|
|
22491
|
+
ownerGeneration: current.ownerGenerations.find((row) => ownerId(row) === ownerId(key))?.generation ?? 0,
|
|
22492
|
+
recipientGeneration: registration.generation,
|
|
22493
|
+
attempts: 0,
|
|
22494
|
+
nextAttemptAt: this.#now()
|
|
22495
|
+
});
|
|
22496
|
+
}
|
|
22497
|
+
return {
|
|
22498
|
+
...clean,
|
|
22499
|
+
pending
|
|
22500
|
+
};
|
|
22501
|
+
});
|
|
22502
|
+
}
|
|
22503
|
+
this.#kick();
|
|
22504
|
+
}
|
|
22505
|
+
async #reconcileKnownScopes() {
|
|
22506
|
+
let snapshot;
|
|
22507
|
+
try {
|
|
22508
|
+
snapshot = this.#registry.snapshot();
|
|
22509
|
+
} catch {
|
|
22510
|
+
return;
|
|
22511
|
+
}
|
|
22512
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
22513
|
+
...snapshot.ownerGenerations.map((row) => row.scopeId),
|
|
22514
|
+
...snapshot.registrations.map((row) => row.scopeId),
|
|
22515
|
+
...snapshot.pending.map((row) => row.key.scopeId),
|
|
22516
|
+
...snapshot.receipts.map((row) => row.key.scopeId),
|
|
22517
|
+
...snapshot.references.map((row) => row.key.scopeId),
|
|
22518
|
+
...[...this.#origins.values()].map((row) => row.scopeId)
|
|
22519
|
+
]);
|
|
22520
|
+
for (const scopeId of scopes) {
|
|
22521
|
+
if (this.#closed) return;
|
|
22522
|
+
const scopeOrigins = [...this.#origins.values()].filter((row) => row.scopeId === scopeId);
|
|
22523
|
+
const origin = scopeOrigins.find((row) => row.active) ?? scopeOrigins[0];
|
|
22524
|
+
if (!origin) continue;
|
|
22525
|
+
const authorized = await this.#readAuthorized(origin);
|
|
22526
|
+
if (this.#closed) return;
|
|
22527
|
+
if (!authorized) continue;
|
|
22528
|
+
const current = this.#registry.snapshot();
|
|
22529
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
22530
|
+
for (const row of current.ownerGenerations) if (row.scopeId === scopeId) candidates.add(row.deviceId);
|
|
22531
|
+
for (const row of current.registrations) if (row.scopeId === scopeId) candidates.add(row.deviceId);
|
|
22532
|
+
for (const row of current.pending) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22533
|
+
for (const row of current.receipts) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22534
|
+
for (const row of current.references) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22535
|
+
const removed = [...candidates].filter((id) => !authorized.has(id));
|
|
22536
|
+
if (removed.length) await this.#purgeDevices(scopeId, removed);
|
|
22537
|
+
}
|
|
22538
|
+
}
|
|
22539
|
+
async #purgeDevices(scopeId, ids) {
|
|
22540
|
+
const waiting = [];
|
|
22541
|
+
const fresh = [];
|
|
22542
|
+
for (const id of new Set(ids)) {
|
|
22543
|
+
const key = JSON.stringify([scopeId, id]);
|
|
22544
|
+
const ongoing = this.#purgeTasks.get(key);
|
|
22545
|
+
if (ongoing) waiting.push(ongoing);
|
|
22546
|
+
else fresh.push(id);
|
|
22547
|
+
}
|
|
22548
|
+
if (fresh.length) {
|
|
22549
|
+
const task = this.#registry.purgeDevices(scopeId, fresh);
|
|
22550
|
+
for (const id of fresh) this.#purgeTasks.set(JSON.stringify([scopeId, id]), task);
|
|
22551
|
+
const release = () => {
|
|
22552
|
+
for (const id of fresh) if (this.#purgeTasks.get(JSON.stringify([scopeId, id])) === task) this.#purgeTasks.delete(JSON.stringify([scopeId, id]));
|
|
22553
|
+
};
|
|
22554
|
+
task.then(release, release);
|
|
22555
|
+
waiting.push(task);
|
|
22556
|
+
}
|
|
22557
|
+
await Promise.all(waiting);
|
|
22558
|
+
}
|
|
22559
|
+
async #reconcilePending() {
|
|
22560
|
+
let snapshot;
|
|
22561
|
+
try {
|
|
22562
|
+
snapshot = this.#registry.snapshot();
|
|
22563
|
+
} catch {
|
|
22564
|
+
return;
|
|
22565
|
+
}
|
|
22566
|
+
for (const intent of snapshot.pending) {
|
|
22567
|
+
if (this.#closed) return;
|
|
22568
|
+
if (intent.source.expiresAt <= this.#now()) {
|
|
22569
|
+
await this.#removeIntent(intent);
|
|
22570
|
+
continue;
|
|
22571
|
+
}
|
|
22572
|
+
if (snapshot.receipts.some((row) => row.sourceId === intent.source.sourceId && sameKey(row.key, intent.key))) {
|
|
22573
|
+
await this.#removePending(intent);
|
|
22574
|
+
continue;
|
|
22575
|
+
}
|
|
22576
|
+
const guard = await this.#sourceGuard(intent.source);
|
|
22577
|
+
if (this.#closed) return;
|
|
22578
|
+
if (guard === "denied") await this.#removeIntent(intent);
|
|
22579
|
+
else if (guard === "unverifiable") this.#defer(intent);
|
|
22580
|
+
}
|
|
22581
|
+
}
|
|
22582
|
+
async #periodicReconcile() {
|
|
22583
|
+
if (this.#closed) return;
|
|
22584
|
+
this.#pruneDeferred();
|
|
22585
|
+
await this.#reconcileKnownScopes();
|
|
22586
|
+
await this.#reconcilePending();
|
|
22587
|
+
this.#kick();
|
|
22588
|
+
}
|
|
22589
|
+
async #reconcileSession(sessionId) {
|
|
22590
|
+
if (this.#closed) return;
|
|
22591
|
+
let snapshot;
|
|
22592
|
+
try {
|
|
22593
|
+
snapshot = this.#registry.snapshot();
|
|
22594
|
+
} catch {
|
|
22595
|
+
return;
|
|
22596
|
+
}
|
|
22597
|
+
for (const intent of snapshot.pending) {
|
|
22598
|
+
if (intent.source.sessionId !== sessionId) continue;
|
|
22599
|
+
const guard = await this.#sourceGuard(intent.source);
|
|
22600
|
+
if (this.#closed) return;
|
|
22601
|
+
if (guard === "denied") await this.#removeIntent(intent);
|
|
22602
|
+
else if (guard === "unverifiable") this.#defer(intent);
|
|
22603
|
+
}
|
|
22604
|
+
this.#kick();
|
|
22605
|
+
}
|
|
22606
|
+
#kick() {
|
|
22607
|
+
if (!this.#started || this.#closed || this.#pumping) return;
|
|
22608
|
+
this.#pumping = true;
|
|
22609
|
+
this.#pump().finally(() => {
|
|
22610
|
+
this.#pumping = false;
|
|
22611
|
+
this.#scheduleWake();
|
|
22612
|
+
});
|
|
22613
|
+
}
|
|
22614
|
+
async #pump() {
|
|
22615
|
+
while (!this.#closed && this.#active < 4) {
|
|
22616
|
+
const test = this.#tests.shift();
|
|
22617
|
+
if (test) {
|
|
22618
|
+
this.#launchTest(test);
|
|
22619
|
+
continue;
|
|
22620
|
+
}
|
|
22621
|
+
let intents;
|
|
22622
|
+
try {
|
|
22623
|
+
intents = this.#registry.snapshot().pending;
|
|
22624
|
+
} catch {
|
|
22625
|
+
return;
|
|
22626
|
+
}
|
|
22627
|
+
const now = this.#now();
|
|
22628
|
+
const intent = [...intents].filter((item) => item.nextAttemptAt <= now && !this.#inFlightIds.has(item.id) && (this.#deferredUntil.get(item.id) ?? 0) <= now).sort((a, b) => a.nextAttemptAt - b.nextAttemptAt || a.source.createdAt - b.source.createdAt)[0];
|
|
22629
|
+
if (!intent) return;
|
|
22630
|
+
this.#deferredUntil.delete(intent.id);
|
|
22631
|
+
this.#launchIntent(intent);
|
|
22632
|
+
}
|
|
22633
|
+
}
|
|
22634
|
+
#launchIntent(intent) {
|
|
22635
|
+
this.#active += 1;
|
|
22636
|
+
this.#inFlightIds.add(intent.id);
|
|
22637
|
+
const task = this.#sendIntent(intent).catch(() => {
|
|
22638
|
+
this.#defer(intent);
|
|
22639
|
+
}).finally(() => {
|
|
22640
|
+
this.#inFlightIds.delete(intent.id);
|
|
22641
|
+
this.#inFlight.delete(intent.id);
|
|
22642
|
+
this.#active -= 1;
|
|
22643
|
+
this.#kick();
|
|
22644
|
+
});
|
|
22645
|
+
this.#tasks.add(task);
|
|
22646
|
+
task.finally(() => this.#tasks.delete(task));
|
|
22647
|
+
}
|
|
22648
|
+
#launchTest(work) {
|
|
22649
|
+
this.#active += 1;
|
|
22650
|
+
const task = this.#sendTest(work).catch(() => void 0).finally(() => {
|
|
22651
|
+
this.#active -= 1;
|
|
22652
|
+
this.#kick();
|
|
22653
|
+
});
|
|
22654
|
+
this.#tasks.add(task);
|
|
22655
|
+
task.finally(() => this.#tasks.delete(task));
|
|
22656
|
+
}
|
|
22657
|
+
async #sendIntent(intent) {
|
|
22658
|
+
if (this.#closed) return;
|
|
22659
|
+
const origin = this.#originFor(intent.key);
|
|
22660
|
+
if (!origin || this.#originDetached(origin)) {
|
|
22661
|
+
this.#defer(intent);
|
|
22662
|
+
return;
|
|
22663
|
+
}
|
|
22664
|
+
const initial = await this.#guard(intent, origin);
|
|
22665
|
+
if (initial === "denied") {
|
|
22666
|
+
await this.#denyOrDefer(intent, origin);
|
|
22667
|
+
return;
|
|
22668
|
+
}
|
|
22669
|
+
if (initial === "unverifiable") {
|
|
22670
|
+
this.#defer(intent);
|
|
22671
|
+
return;
|
|
22672
|
+
}
|
|
22673
|
+
const setup = await this.#ensureReference(intent);
|
|
22674
|
+
if (!setup) return;
|
|
22675
|
+
const { registration, keys, reference } = setup;
|
|
22676
|
+
const controller = new AbortController();
|
|
22677
|
+
this.#inFlight.set(intent.id, {
|
|
22678
|
+
controller,
|
|
22679
|
+
key: intent.key,
|
|
22680
|
+
origin
|
|
22681
|
+
});
|
|
22682
|
+
if (this.#closed) controller.abort();
|
|
22683
|
+
const remaining = Math.floor((intent.source.expiresAt - this.#now()) / 1e3);
|
|
22684
|
+
if (remaining < 1) {
|
|
22685
|
+
await this.#removeIntent(intent);
|
|
22686
|
+
return;
|
|
22687
|
+
}
|
|
22688
|
+
const payload = buildDeclarativePayload(intent.source.category, registration.locale, `/?mobile-push=${reference}`, intent.source.sourceId.replace(/[^A-Za-z0-9._:-]/gu, "_").slice(0, 128) || "push");
|
|
22689
|
+
let result;
|
|
22690
|
+
try {
|
|
22691
|
+
result = await this.#sender.send({
|
|
22692
|
+
registration,
|
|
22693
|
+
payload,
|
|
22694
|
+
ttlSeconds: Math.min(remaining, 1800),
|
|
22695
|
+
subject: origin.origin,
|
|
22696
|
+
keys,
|
|
22697
|
+
signal: controller.signal,
|
|
22698
|
+
beforeRequest: async (signal) => signal.aborted || controller.signal.aborted ? false : await this.#guard(intent, origin) === "allowed"
|
|
22699
|
+
});
|
|
22700
|
+
} catch {
|
|
22701
|
+
result = { kind: "retry" };
|
|
22702
|
+
}
|
|
22703
|
+
if (this.#closed) return;
|
|
22704
|
+
if (result.kind === "accepted") {
|
|
22705
|
+
await this.#settleIntent(intent, result);
|
|
22706
|
+
return;
|
|
22707
|
+
}
|
|
22708
|
+
if (result.kind === "cancelled") {
|
|
22709
|
+
const current = await this.#guard(intent, origin);
|
|
22710
|
+
if (current === "denied") await this.#denyOrDefer(intent, origin);
|
|
22711
|
+
else if (current === "unverifiable") this.#defer(intent);
|
|
22712
|
+
else await this.#settleIntent(intent, { kind: "retry" });
|
|
22713
|
+
return;
|
|
22714
|
+
}
|
|
22715
|
+
const current = await this.#guard(intent, origin);
|
|
22716
|
+
if (current === "denied") {
|
|
22717
|
+
await this.#denyOrDefer(intent, origin);
|
|
22718
|
+
return;
|
|
22719
|
+
}
|
|
22720
|
+
if (current === "unverifiable") {
|
|
22721
|
+
this.#defer(intent);
|
|
22722
|
+
return;
|
|
22723
|
+
}
|
|
22724
|
+
await this.#settleIntent(intent, result);
|
|
22725
|
+
}
|
|
22726
|
+
async #sendTest(work) {
|
|
22727
|
+
if (this.#closed) {
|
|
22728
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22729
|
+
return;
|
|
22730
|
+
}
|
|
22731
|
+
const origin = this.#originFor(work.key);
|
|
22732
|
+
if (!origin) {
|
|
22733
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22734
|
+
return;
|
|
22735
|
+
}
|
|
22736
|
+
const fakeSource = {
|
|
22737
|
+
sourceId: `test:${randomUUID()}`,
|
|
22738
|
+
sessionId: "",
|
|
22739
|
+
targetSessionId: "",
|
|
22740
|
+
category: "response",
|
|
22741
|
+
createdAt: this.#now(),
|
|
22742
|
+
expiresAt: this.#now() + 6e4,
|
|
22743
|
+
lifetime: "committed"
|
|
22744
|
+
};
|
|
22745
|
+
if (await this.#guardKey(work.key, fakeSource, origin, false) !== "allowed") {
|
|
22746
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22747
|
+
return;
|
|
22748
|
+
}
|
|
22749
|
+
let snapshot;
|
|
22750
|
+
try {
|
|
22751
|
+
snapshot = this.#registry.snapshot();
|
|
22752
|
+
} catch {
|
|
22753
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22754
|
+
return;
|
|
22755
|
+
}
|
|
22756
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, work.key));
|
|
22757
|
+
const keys = snapshot.keys;
|
|
22758
|
+
if (!registration || !keys) {
|
|
22759
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22760
|
+
return;
|
|
22761
|
+
}
|
|
22762
|
+
const controller = new AbortController();
|
|
22763
|
+
const workId = `test:${randomUUID()}`;
|
|
22764
|
+
this.#inFlight.set(workId, {
|
|
22765
|
+
controller,
|
|
22766
|
+
key: work.key,
|
|
22767
|
+
origin
|
|
22768
|
+
});
|
|
22769
|
+
let result;
|
|
22770
|
+
try {
|
|
22771
|
+
result = await this.#sender.send({
|
|
22772
|
+
registration,
|
|
22773
|
+
payload: buildDeclarativePayload("test", registration.locale, "/", `test-${randomUUID()}`),
|
|
22774
|
+
ttlSeconds: 60,
|
|
22775
|
+
subject: origin.origin,
|
|
22776
|
+
keys,
|
|
22777
|
+
signal: controller.signal,
|
|
22778
|
+
beforeRequest: async (signal) => !signal.aborted && !controller.signal.aborted && await this.#guardKey(work.key, fakeSource, origin, false, registration.generation) === "allowed"
|
|
22779
|
+
});
|
|
22780
|
+
} catch {
|
|
22781
|
+
result = { kind: "retry" };
|
|
22782
|
+
} finally {
|
|
22783
|
+
this.#inFlight.delete(workId);
|
|
22784
|
+
}
|
|
22785
|
+
if (!this.#closed && (result.kind === "gone" || result.kind === "suspended")) try {
|
|
22786
|
+
await this.#registry.settleRecipient(work.key, registration.generation, result.kind);
|
|
22787
|
+
} catch {}
|
|
22788
|
+
const finalGuard = result.kind === "accepted" && !this.#closed ? await this.#guardKey(work.key, fakeSource, origin, false, registration.generation) : "denied";
|
|
22789
|
+
if (result.kind === "accepted" && finalGuard === "allowed" && !this.#closed) work.resolve();
|
|
22790
|
+
else work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22791
|
+
}
|
|
22792
|
+
async #guard(intent, origin) {
|
|
22793
|
+
return this.#guardKey(intent.key, intent.source, origin, true, intent.recipientGeneration);
|
|
22794
|
+
}
|
|
22795
|
+
/** Maps the Host tri-state for durable-work decisions; boolean eligible() would collapse transient uncertainty into denial. */
|
|
22796
|
+
async #sourceGuard(source) {
|
|
22797
|
+
if (!this.#host.available) return "unverifiable";
|
|
22798
|
+
let result;
|
|
22799
|
+
try {
|
|
22800
|
+
result = await this.#host.reconcile(source);
|
|
22801
|
+
} catch {
|
|
22802
|
+
return "unverifiable";
|
|
22803
|
+
}
|
|
22804
|
+
if (result === "eligible") return "allowed";
|
|
22805
|
+
if (result === "stale") return "denied";
|
|
22806
|
+
if (source.category === "plan" || source.category === "approval" || source.category === "question" && typeof source.callId !== "string") return "denied";
|
|
22807
|
+
return "unverifiable";
|
|
22808
|
+
}
|
|
22809
|
+
async #guardKey(key, source, origin, requireEligible, expectedGeneration) {
|
|
22810
|
+
const epoch = origin.generation;
|
|
22811
|
+
if (this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin || source.expiresAt <= this.#now()) return "denied";
|
|
22812
|
+
if (!this.#host.available) return "unverifiable";
|
|
22813
|
+
let first;
|
|
22814
|
+
try {
|
|
22815
|
+
first = this.#registration(key);
|
|
22816
|
+
} catch {
|
|
22817
|
+
return "unverifiable";
|
|
22818
|
+
}
|
|
22819
|
+
if (!first || !first.enabled || first.health !== "ready" || expectedGeneration !== void 0 && first.generation !== expectedGeneration || requireEligible && !isPreferenceEnabled(first.preferences, source.category)) return "denied";
|
|
22820
|
+
if (requireEligible) {
|
|
22821
|
+
const guard = await this.#sourceGuard(source);
|
|
22822
|
+
if (guard === "denied") return "denied";
|
|
22823
|
+
if (guard === "unverifiable") return "unverifiable";
|
|
22824
|
+
}
|
|
22825
|
+
const auth = await this.#readAuthorized(origin);
|
|
22826
|
+
if (!auth) return "unverifiable";
|
|
22827
|
+
const device = auth.get(key.deviceId);
|
|
22828
|
+
if (!device || device.expiresAt <= this.#now()) return "denied";
|
|
22829
|
+
if (this.#closed || !origin.active || origin.generation !== epoch || this.#origins.get(this.#originId(origin)) !== origin || source.expiresAt <= this.#now()) return "denied";
|
|
22830
|
+
let latest;
|
|
22831
|
+
let latestFingerprint;
|
|
22832
|
+
try {
|
|
22833
|
+
latest = this.#registration(key);
|
|
22834
|
+
latestFingerprint = this.#registry.snapshot().keys?.fingerprint;
|
|
22835
|
+
} catch {
|
|
22836
|
+
return "unverifiable";
|
|
22837
|
+
}
|
|
22838
|
+
if (!latest || !latest.enabled || latest.health !== "ready" || latest.generation !== first.generation || latest.keyFingerprint !== first.keyFingerprint || latestFingerprint !== latest.keyFingerprint || expectedGeneration !== void 0 && latest.generation !== expectedGeneration || requireEligible && !isPreferenceEnabled(latest.preferences, source.category)) return "denied";
|
|
22839
|
+
return "allowed";
|
|
22840
|
+
}
|
|
22841
|
+
async #ensureReference(intent) {
|
|
22842
|
+
let reference = "";
|
|
22843
|
+
let registration;
|
|
22844
|
+
let keys;
|
|
22845
|
+
try {
|
|
22846
|
+
await this.#write((snapshot) => {
|
|
22847
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22848
|
+
if (!pendingHas(clean, intent)) return clean;
|
|
22849
|
+
registration = clean.registrations.find((row) => sameKey(row, intent.key) && row.generation === intent.recipientGeneration);
|
|
22850
|
+
keys = clean.keys;
|
|
22851
|
+
if (!registration || !keys) return clean;
|
|
22852
|
+
const old = clean.references.find((row) => row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key) && row.expiresAt > this.#now());
|
|
22853
|
+
reference = old?.reference ?? randomBytes(24).toString("base64url");
|
|
22854
|
+
if (!old) {
|
|
22855
|
+
const rows = clean.references.filter((row) => row.key.scopeId !== intent.key.scopeId);
|
|
22856
|
+
const kept = clean.references.filter((row) => row.key.scopeId === intent.key.scopeId).slice(-1023);
|
|
22857
|
+
return {
|
|
22858
|
+
...clean,
|
|
22859
|
+
references: [
|
|
22860
|
+
...rows,
|
|
22861
|
+
...kept,
|
|
22862
|
+
{
|
|
22863
|
+
reference,
|
|
22864
|
+
key: intent.key,
|
|
22865
|
+
source: structuredClone(intent.source),
|
|
22866
|
+
expiresAt: intent.source.expiresAt
|
|
22867
|
+
}
|
|
22868
|
+
]
|
|
22869
|
+
};
|
|
22870
|
+
}
|
|
22871
|
+
return clean;
|
|
22872
|
+
});
|
|
22873
|
+
} catch {
|
|
22874
|
+
this.#defer(intent);
|
|
22875
|
+
return;
|
|
22876
|
+
}
|
|
22877
|
+
return registration && keys && reference ? {
|
|
22878
|
+
registration,
|
|
22879
|
+
keys,
|
|
22880
|
+
reference
|
|
22881
|
+
} : void 0;
|
|
22882
|
+
}
|
|
22883
|
+
async #settleIntent(intent, result) {
|
|
22884
|
+
if (result.kind === "accepted") {
|
|
22885
|
+
await this.#write((snapshot) => {
|
|
22886
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22887
|
+
if (!pendingHas(clean, intent) || intent.source.expiresAt <= this.#now()) return clean;
|
|
22888
|
+
if (!recipientCurrent(clean, intent)) return {
|
|
22889
|
+
...clean,
|
|
22890
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22891
|
+
references: clean.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22892
|
+
};
|
|
22893
|
+
const duplicate = clean.receipts.some((row) => row.sourceId === intent.source.sourceId && sameKey(row.key, intent.key));
|
|
22894
|
+
const rows = clean.receipts.filter((row) => row.key.scopeId !== intent.key.scopeId);
|
|
22895
|
+
const sameScope = clean.receipts.filter((row) => row.key.scopeId === intent.key.scopeId);
|
|
22896
|
+
const receipt = {
|
|
22897
|
+
sourceId: intent.source.sourceId,
|
|
22898
|
+
key: intent.key,
|
|
22899
|
+
recipientGeneration: intent.recipientGeneration,
|
|
22900
|
+
expiresAt: intent.source.expiresAt
|
|
22901
|
+
};
|
|
22902
|
+
const receipts = duplicate ? clean.receipts : [
|
|
22903
|
+
...rows,
|
|
22904
|
+
...sameScope.slice(-1023),
|
|
22905
|
+
receipt
|
|
22906
|
+
];
|
|
22907
|
+
return {
|
|
22908
|
+
...clean,
|
|
22909
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22910
|
+
receipts
|
|
22911
|
+
};
|
|
22912
|
+
});
|
|
22913
|
+
return;
|
|
22914
|
+
}
|
|
22915
|
+
if (result.kind === "gone" || result.kind === "suspended") {
|
|
22916
|
+
await this.#registry.settleRecipient(intent.key, intent.recipientGeneration, result.kind);
|
|
22917
|
+
await this.#removeIntent(intent);
|
|
22918
|
+
return;
|
|
22919
|
+
}
|
|
22920
|
+
if (result.kind === "permanent") {
|
|
22921
|
+
await this.#removeIntent(intent);
|
|
22922
|
+
return;
|
|
22923
|
+
}
|
|
22924
|
+
if (result.kind !== "retry") {
|
|
22925
|
+
await this.#removeIntent(intent);
|
|
22926
|
+
return;
|
|
22927
|
+
}
|
|
22928
|
+
const attempt = intent.attempts + 1;
|
|
22929
|
+
const remainingMs = intent.source.expiresAt - this.#now();
|
|
22930
|
+
if (attempt >= 3 || remainingMs <= 0) {
|
|
22931
|
+
await this.#removeIntent(intent);
|
|
22932
|
+
return;
|
|
22933
|
+
}
|
|
22934
|
+
const retryAfter = result.retryAfterMs;
|
|
22935
|
+
const backoff = RETRY_BACKOFF_MS[attempt - 1] ?? 5e3;
|
|
22936
|
+
const delay = Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : backoff;
|
|
22937
|
+
if (delay >= remainingMs) {
|
|
22938
|
+
await this.#removeIntent(intent);
|
|
22939
|
+
return;
|
|
22940
|
+
}
|
|
22941
|
+
await this.#write((snapshot) => {
|
|
22942
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22943
|
+
if (!pendingHas(clean, intent)) return clean;
|
|
22944
|
+
if (!recipientCurrent(clean, intent)) return {
|
|
22945
|
+
...clean,
|
|
22946
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22947
|
+
references: clean.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22948
|
+
};
|
|
22949
|
+
return {
|
|
22950
|
+
...clean,
|
|
22951
|
+
pending: clean.pending.map((row) => row.id === intent.id ? {
|
|
22952
|
+
...row,
|
|
22953
|
+
attempts: attempt,
|
|
22954
|
+
nextAttemptAt: this.#now() + delay
|
|
22955
|
+
} : row)
|
|
22956
|
+
};
|
|
22957
|
+
});
|
|
22958
|
+
}
|
|
22959
|
+
async #removePending(intent) {
|
|
22960
|
+
await this.#write((snapshot) => ({
|
|
22961
|
+
...boundedSnapshot(snapshot, this.#now()),
|
|
22962
|
+
pending: snapshot.pending.filter((row) => row.id !== intent.id)
|
|
22963
|
+
}));
|
|
22964
|
+
}
|
|
22965
|
+
async #removeIntent(intent) {
|
|
22966
|
+
await this.#write((snapshot) => ({
|
|
22967
|
+
...boundedSnapshot(snapshot, this.#now()),
|
|
22968
|
+
pending: snapshot.pending.filter((row) => row.id !== intent.id),
|
|
22969
|
+
references: snapshot.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22970
|
+
}));
|
|
22971
|
+
}
|
|
22972
|
+
#defer(intent) {
|
|
22973
|
+
this.#deferredUntil.set(intent.id, this.#now() + UNVERIFIABLE_RECHECK_MS);
|
|
22974
|
+
}
|
|
22975
|
+
/** Defer keys live only as long as their owning pending intent survives every removal path. */
|
|
22976
|
+
#pruneDeferred() {
|
|
22977
|
+
let pending;
|
|
22978
|
+
try {
|
|
22979
|
+
pending = this.#registry.snapshot().pending;
|
|
22980
|
+
} catch {
|
|
22981
|
+
return;
|
|
22982
|
+
}
|
|
22983
|
+
pruneOrphanedDeferrals(this.#deferredUntil, pending);
|
|
22984
|
+
}
|
|
22985
|
+
async #readAuthorized(origin) {
|
|
22986
|
+
try {
|
|
22987
|
+
const rows = await origin.authorizedDevices();
|
|
22988
|
+
if (!Array.isArray(rows)) return void 0;
|
|
22989
|
+
const result = /* @__PURE__ */ new Map();
|
|
22990
|
+
for (const row of rows) {
|
|
22991
|
+
if (!row || typeof row.id !== "string" || row.id.length === 0 || !Number.isFinite(row.expiresAt)) return void 0;
|
|
22992
|
+
result.set(row.id, row);
|
|
22993
|
+
}
|
|
22994
|
+
return result;
|
|
22995
|
+
} catch {
|
|
22996
|
+
return;
|
|
22997
|
+
}
|
|
22998
|
+
}
|
|
22999
|
+
#registration(key) {
|
|
23000
|
+
const snapshot = this.#registry.snapshot();
|
|
23001
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, key));
|
|
23002
|
+
if (!registration || snapshot.keys?.fingerprint !== registration.keyFingerprint) return void 0;
|
|
23003
|
+
return registration;
|
|
23004
|
+
}
|
|
23005
|
+
#originFor(key) {
|
|
23006
|
+
return this.#origins.get(JSON.stringify([key.scopeId, key.origin]));
|
|
23007
|
+
}
|
|
23008
|
+
#originId(origin) {
|
|
23009
|
+
return JSON.stringify([origin.scopeId, origin.origin]);
|
|
23010
|
+
}
|
|
23011
|
+
/** True when the origin object is inactive or has been replaced — a transient state, never authorization evidence. */
|
|
23012
|
+
#originDetached(origin) {
|
|
23013
|
+
return !origin.active || this.#origins.get(this.#originId(origin)) !== origin;
|
|
23014
|
+
}
|
|
23015
|
+
/** Defers rather than removes when the denial could stem from origin state; removal stays reserved for authorization-backed denial. */
|
|
23016
|
+
async #denyOrDefer(intent, origin) {
|
|
23017
|
+
if (this.#originDetached(origin)) this.#defer(intent);
|
|
23018
|
+
else await this.#removeIntent(intent);
|
|
23019
|
+
}
|
|
23020
|
+
#clearOriginDeferrals(origin) {
|
|
23021
|
+
if (this.#deferredUntil.size === 0) return;
|
|
23022
|
+
let pending;
|
|
23023
|
+
try {
|
|
23024
|
+
pending = this.#registry.snapshot().pending;
|
|
23025
|
+
} catch {
|
|
23026
|
+
return;
|
|
23027
|
+
}
|
|
23028
|
+
for (const intent of pending) if (intent.key.scopeId === origin.scopeId && intent.key.origin === origin.origin) this.#deferredUntil.delete(intent.id);
|
|
23029
|
+
}
|
|
23030
|
+
#write(update) {
|
|
23031
|
+
const operation = this.#writeQueue.then(() => this.#registry.transact((snapshot) => update(boundedSnapshot(snapshot, this.#now()))));
|
|
23032
|
+
this.#writeQueue = operation.catch(() => void 0);
|
|
23033
|
+
return operation;
|
|
23034
|
+
}
|
|
23035
|
+
#scheduleWake() {
|
|
23036
|
+
if (this.#closed) return;
|
|
23037
|
+
if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
|
|
23038
|
+
this.#wakeTimer = void 0;
|
|
23039
|
+
this.#pruneDeferred();
|
|
23040
|
+
let nextAt = Number.POSITIVE_INFINITY;
|
|
23041
|
+
try {
|
|
23042
|
+
for (const intent of this.#registry.snapshot().pending) {
|
|
23043
|
+
if (this.#inFlightIds.has(intent.id)) continue;
|
|
23044
|
+
nextAt = Math.min(nextAt, Math.max(intent.nextAttemptAt, this.#deferredUntil.get(intent.id) ?? 0));
|
|
23045
|
+
}
|
|
23046
|
+
} catch {
|
|
23047
|
+
return;
|
|
23048
|
+
}
|
|
23049
|
+
if (this.#tests.length) nextAt = this.#now();
|
|
23050
|
+
if (!Number.isFinite(nextAt)) return;
|
|
23051
|
+
const delay = Math.max(0, nextAt - this.#now());
|
|
23052
|
+
this.#wakeTimer = setTimeout(() => {
|
|
23053
|
+
this.#wakeTimer = void 0;
|
|
23054
|
+
this.#kick();
|
|
23055
|
+
}, delay);
|
|
23056
|
+
}
|
|
23057
|
+
};
|
|
23058
|
+
//#endregion
|
|
23059
|
+
//#region src/mobile-push/gateway.ts
|
|
23060
|
+
/** One service shared by every Gateway while each Gateway owns a separate auth/origin attachment. */
|
|
23061
|
+
var PushGatewayCoordinator = class {
|
|
23062
|
+
host;
|
|
23063
|
+
registry;
|
|
23064
|
+
delivery;
|
|
23065
|
+
origins = /* @__PURE__ */ new Map();
|
|
23066
|
+
closed = false;
|
|
23067
|
+
constructor(host, registry, delivery) {
|
|
23068
|
+
this.host = host;
|
|
23069
|
+
this.registry = registry;
|
|
23070
|
+
this.delivery = delivery;
|
|
23071
|
+
}
|
|
23072
|
+
attachOrigin(binding) {
|
|
23073
|
+
if (this.closed) return () => void 0;
|
|
23074
|
+
if (!binding.scopeId || !binding.origin) throw new Error("push_origin_invalid");
|
|
23075
|
+
let parsed;
|
|
23076
|
+
try {
|
|
23077
|
+
parsed = new URL(binding.origin);
|
|
23078
|
+
} catch {
|
|
23079
|
+
throw new Error("push_origin_invalid");
|
|
23080
|
+
}
|
|
23081
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== binding.origin || parsed.username !== "" || parsed.password !== "" || parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "") throw new Error("push_origin_invalid");
|
|
23082
|
+
const id = JSON.stringify([binding.scopeId, binding.origin]);
|
|
23083
|
+
if (this.origins.has(id)) throw new Error("push_origin_already_attached");
|
|
23084
|
+
const detachDelivery = this.delivery.attachOrigin(binding);
|
|
23085
|
+
const row = {
|
|
23086
|
+
binding,
|
|
23087
|
+
detachDelivery
|
|
23088
|
+
};
|
|
23089
|
+
this.origins.set(id, row);
|
|
23090
|
+
let active = true;
|
|
23091
|
+
return () => {
|
|
23092
|
+
if (!active) return;
|
|
23093
|
+
active = false;
|
|
23094
|
+
if (this.origins.get(id) === row) this.origins.delete(id);
|
|
23095
|
+
detachDelivery();
|
|
23096
|
+
};
|
|
23097
|
+
}
|
|
23098
|
+
async invalidateDevices(scopeId, deviceIds) {
|
|
23099
|
+
await this.delivery.invalidateDevices(scopeId, deviceIds);
|
|
23100
|
+
}
|
|
23101
|
+
async reset() {
|
|
23102
|
+
await this.registry.resetKeys();
|
|
23103
|
+
}
|
|
23104
|
+
async config(owner, installationId) {
|
|
23105
|
+
const authorized = await this.authorize(owner);
|
|
23106
|
+
const config = await this.registry.config(owner, installationId);
|
|
23107
|
+
return {
|
|
23108
|
+
...config,
|
|
23109
|
+
available: config.available && this.host.available && authorized
|
|
23110
|
+
};
|
|
23111
|
+
}
|
|
23112
|
+
async enroll(owner, input) {
|
|
23113
|
+
if (!this.host.available) throw new Error("push_unavailable");
|
|
23114
|
+
await this.authorize(owner);
|
|
23115
|
+
let subscription;
|
|
23116
|
+
try {
|
|
23117
|
+
subscription = validatePushSubscription(input.subscription);
|
|
23118
|
+
} catch (error) {
|
|
23119
|
+
throw new Error(error instanceof Error ? error.message : "push_subscription_invalid");
|
|
23120
|
+
}
|
|
23121
|
+
return this.registry.upsert(owner, {
|
|
23122
|
+
...input,
|
|
23123
|
+
subscription
|
|
23124
|
+
});
|
|
23125
|
+
}
|
|
23126
|
+
async preferences(owner, installationId, value, guard) {
|
|
23127
|
+
await this.authorize(owner);
|
|
23128
|
+
return this.registry.preferences({
|
|
23129
|
+
...owner,
|
|
23130
|
+
installationId
|
|
23131
|
+
}, value, guard);
|
|
23132
|
+
}
|
|
23133
|
+
async disable(owner, installationId, ifGeneration) {
|
|
23134
|
+
await this.authorize(owner);
|
|
23135
|
+
await this.registry.disable({
|
|
23136
|
+
...owner,
|
|
23137
|
+
installationId
|
|
23138
|
+
}, ifGeneration);
|
|
23139
|
+
}
|
|
23140
|
+
async logout(owner) {
|
|
23141
|
+
await this.authorize(owner);
|
|
23142
|
+
await this.registry.disableOwner(owner);
|
|
23143
|
+
}
|
|
23144
|
+
async test(owner, installationId) {
|
|
23145
|
+
if (!this.host.available) throw new Error("push_unavailable");
|
|
23146
|
+
await this.authorize(owner);
|
|
23147
|
+
await this.delivery.test({
|
|
23148
|
+
...owner,
|
|
23149
|
+
installationId
|
|
23150
|
+
});
|
|
23151
|
+
}
|
|
23152
|
+
async resolve(owner, reference) {
|
|
23153
|
+
if (!this.host.available || !await this.authorize(owner, true)) return void 0;
|
|
23154
|
+
return this.delivery.resolve(owner, reference);
|
|
23155
|
+
}
|
|
23156
|
+
/** Close only this service's origin attachments; its owning plugin closes shared Delivery/Host/Registry. */
|
|
23157
|
+
close() {
|
|
23158
|
+
if (this.closed) return;
|
|
23159
|
+
this.closed = true;
|
|
23160
|
+
for (const row of this.origins.values()) row.detachDelivery();
|
|
23161
|
+
this.origins.clear();
|
|
23162
|
+
}
|
|
23163
|
+
async authorize(owner, neutralMissing = false) {
|
|
23164
|
+
const row = this.origins.get(JSON.stringify([owner.scopeId, owner.origin]));
|
|
23165
|
+
if (!row) {
|
|
23166
|
+
if (neutralMissing) return false;
|
|
23167
|
+
throw new Error("push_unavailable");
|
|
23168
|
+
}
|
|
23169
|
+
let devices;
|
|
23170
|
+
try {
|
|
23171
|
+
devices = await row.binding.authorizedDevices();
|
|
23172
|
+
} catch {
|
|
23173
|
+
throw new Error("push_unavailable");
|
|
23174
|
+
}
|
|
23175
|
+
if (!devices.some((device) => device.id === owner.deviceId && device.expiresAt > Date.now())) {
|
|
23176
|
+
if (neutralMissing) return false;
|
|
23177
|
+
throw new Error("push_unauthorized");
|
|
23178
|
+
}
|
|
23179
|
+
return true;
|
|
23180
|
+
}
|
|
23181
|
+
};
|
|
23182
|
+
//#endregion
|
|
23183
|
+
//#region src/installation-id.ts
|
|
23184
|
+
const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
|
|
23185
|
+
const INSTALLATION_ID_BYTES = 65;
|
|
23186
|
+
async function readInstallationId(file) {
|
|
23187
|
+
const entry = await lstat(file);
|
|
23188
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== INSTALLATION_ID_BYTES) throw new Error("installation_id_invalid");
|
|
23189
|
+
const contents = await readFile(file, "utf8");
|
|
23190
|
+
if (!INSTALLATION_ID_PATTERN.test(contents)) throw new Error("installation_id_invalid");
|
|
23191
|
+
await restrictPrivateFile(file);
|
|
23192
|
+
return contents.slice(0, -1);
|
|
23193
|
+
}
|
|
23194
|
+
/** Keep one random plugin identity across setup changes, restarts, and FRP resets; creation requires hard-link support in the state directory. */
|
|
23195
|
+
async function loadOrCreateInstallationId(file) {
|
|
23196
|
+
if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
|
|
23197
|
+
const target = resolve(file);
|
|
23198
|
+
const directory = dirname(target);
|
|
23199
|
+
await ensureManagedDirectory(directory, directory, "installation_id");
|
|
23200
|
+
try {
|
|
23201
|
+
return await readInstallationId(target);
|
|
23202
|
+
} catch (error) {
|
|
23203
|
+
if (error.code !== "ENOENT") throw error;
|
|
23204
|
+
}
|
|
23205
|
+
const temporary = join(directory, `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
23206
|
+
let created = false;
|
|
23207
|
+
let failure;
|
|
23208
|
+
try {
|
|
23209
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
23210
|
+
const handle = await open(temporary, "wx", 384);
|
|
23211
|
+
created = true;
|
|
23212
|
+
let writeFailure;
|
|
23213
|
+
try {
|
|
23214
|
+
await handle.writeFile(`${randomBytes(32).toString("hex")}\n`, "utf8");
|
|
23215
|
+
} catch (error) {
|
|
23216
|
+
writeFailure = error;
|
|
23217
|
+
throw error;
|
|
23218
|
+
} finally {
|
|
23219
|
+
try {
|
|
23220
|
+
await handle.close();
|
|
23221
|
+
} catch (closeError) {
|
|
23222
|
+
if (writeFailure !== void 0) throw new AggregateError([writeFailure, closeError], "installation ID candidate write and close failed");
|
|
23223
|
+
throw closeError;
|
|
23224
|
+
}
|
|
23225
|
+
}
|
|
23226
|
+
await restrictPrivateFile(temporary);
|
|
23227
|
+
await assertManagedParents(directory, target, "installation_id");
|
|
23228
|
+
try {
|
|
23229
|
+
await link(temporary, target);
|
|
23230
|
+
} catch (error) {
|
|
23231
|
+
if (error.code !== "EEXIST") throw new Error("installation ID publication failed; the state filesystem must support hard links", { cause: error });
|
|
23232
|
+
}
|
|
23233
|
+
return await readInstallationId(target);
|
|
23234
|
+
} catch (error) {
|
|
23235
|
+
failure = error;
|
|
23236
|
+
throw error;
|
|
23237
|
+
} finally {
|
|
23238
|
+
if (created) try {
|
|
23239
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
23240
|
+
await unlink(temporary);
|
|
23241
|
+
} catch (cleanupError) {
|
|
23242
|
+
if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
|
|
23243
|
+
throw cleanupError;
|
|
23244
|
+
}
|
|
23245
|
+
}
|
|
23246
|
+
}
|
|
23247
|
+
//#endregion
|
|
23248
|
+
//#region src/gateway-identity.ts
|
|
23249
|
+
/**
|
|
23250
|
+
* Attach the authenticated installation identity while retaining existing entry identity rules.
|
|
23251
|
+
* @param config - Validated gateway configuration containing the legacy identity.
|
|
23252
|
+
* @param installationId - Confirmed random identity read from this installation's state.
|
|
23253
|
+
* @param explicitIdentity - Whether the operator supplied the gateway identity.
|
|
23254
|
+
* @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
|
|
23255
|
+
*/
|
|
23256
|
+
function withInstallationIdentity(config, installationId, explicitIdentity) {
|
|
19848
23257
|
if (config.pairingCaFile !== void 0 || explicitIdentity) return Object.freeze({
|
|
19849
23258
|
...config,
|
|
19850
23259
|
installationId
|
|
@@ -19939,6 +23348,31 @@ const inject = [
|
|
|
19939
23348
|
"commands",
|
|
19940
23349
|
"connection"
|
|
19941
23350
|
];
|
|
23351
|
+
const pushHostServices = [
|
|
23352
|
+
"userQuestions",
|
|
23353
|
+
"approval",
|
|
23354
|
+
"sessionProjections",
|
|
23355
|
+
"sessionQuery",
|
|
23356
|
+
"agents"
|
|
23357
|
+
];
|
|
23358
|
+
const unavailablePushPreferences = Object.freeze({
|
|
23359
|
+
question: true,
|
|
23360
|
+
plan: true,
|
|
23361
|
+
approval: true,
|
|
23362
|
+
response: true,
|
|
23363
|
+
goal: true,
|
|
23364
|
+
failure: true
|
|
23365
|
+
});
|
|
23366
|
+
function unavailablePushConfig(installationId) {
|
|
23367
|
+
return {
|
|
23368
|
+
available: false,
|
|
23369
|
+
installationId,
|
|
23370
|
+
ownerGeneration: 0,
|
|
23371
|
+
registrationGeneration: 0,
|
|
23372
|
+
status: "disabled",
|
|
23373
|
+
preferences: unavailablePushPreferences
|
|
23374
|
+
};
|
|
23375
|
+
}
|
|
19942
23376
|
/** Run cleanup steps in ownership order and report every failure after all steps settle. */
|
|
19943
23377
|
async function settleCleanupSteps(steps) {
|
|
19944
23378
|
const errors = [];
|
|
@@ -20262,6 +23696,9 @@ function locateIrohCompanionRoots(importMetaUrl, environment = process.env) {
|
|
|
20262
23696
|
async function apply(ctx, config) {
|
|
20263
23697
|
const dshVersion = installedDshVersion();
|
|
20264
23698
|
const loaded = await loadSetup(config);
|
|
23699
|
+
/** Rebind overrides the managed setup at runtime; the file on disk is the source of truth on restart. */
|
|
23700
|
+
let managedSetupOverride;
|
|
23701
|
+
let lanRebindRecoveryRequired = false;
|
|
20265
23702
|
const mobileAccess = createMobileAccessService(ctx);
|
|
20266
23703
|
const baseTemplate = loopbackTemplate(loaded, ctx.webServer.port);
|
|
20267
23704
|
const upstreamLoginUrl = upstreamAuthenticatedUrl(ctx, baseTemplate.upstreamOrigin);
|
|
@@ -20284,6 +23721,210 @@ async function apply(ctx, config) {
|
|
|
20284
23721
|
logger.info("task event=%s fields=%o", event, fields);
|
|
20285
23722
|
}
|
|
20286
23723
|
});
|
|
23724
|
+
let pushHost;
|
|
23725
|
+
let pushRegistry;
|
|
23726
|
+
let pushDelivery;
|
|
23727
|
+
let pushGatewayService;
|
|
23728
|
+
let pushRuntimeReady;
|
|
23729
|
+
let resolvePushRuntimeReady;
|
|
23730
|
+
let activePushRuntimeGeneration;
|
|
23731
|
+
const pushOriginAttachments = /* @__PURE__ */ new Map();
|
|
23732
|
+
const pendingDeviceInvalidations = /* @__PURE__ */ new Map();
|
|
23733
|
+
const beginPushRuntimeReadiness = () => {
|
|
23734
|
+
if (pushGatewayService !== void 0) return Promise.resolve(pushGatewayService);
|
|
23735
|
+
pushRuntimeReady ??= new Promise((resolve) => {
|
|
23736
|
+
resolvePushRuntimeReady = resolve;
|
|
23737
|
+
});
|
|
23738
|
+
return pushRuntimeReady;
|
|
23739
|
+
};
|
|
23740
|
+
const settlePushRuntimeReadiness = (service) => {
|
|
23741
|
+
const resolve = resolvePushRuntimeReady;
|
|
23742
|
+
resolvePushRuntimeReady = void 0;
|
|
23743
|
+
pushRuntimeReady = void 0;
|
|
23744
|
+
resolve?.(service);
|
|
23745
|
+
};
|
|
23746
|
+
const setPushGatewayService = (service) => {
|
|
23747
|
+
for (const attachment of pushOriginAttachments.values()) {
|
|
23748
|
+
try {
|
|
23749
|
+
attachment.detach?.();
|
|
23750
|
+
} catch {
|
|
23751
|
+
logger.warn("declarative push origin detach failed");
|
|
23752
|
+
}
|
|
23753
|
+
attachment.detach = void 0;
|
|
23754
|
+
}
|
|
23755
|
+
pushGatewayService = service;
|
|
23756
|
+
if (service !== void 0) for (const attachment of pushOriginAttachments.values()) try {
|
|
23757
|
+
attachment.detach = service.attachOrigin(attachment.binding);
|
|
23758
|
+
} catch {
|
|
23759
|
+
logger.warn("declarative push origin attach failed; Mobile access remains active");
|
|
23760
|
+
}
|
|
23761
|
+
settlePushRuntimeReadiness(service);
|
|
23762
|
+
};
|
|
23763
|
+
const currentPushGatewayService = async () => {
|
|
23764
|
+
if (pushGatewayService !== void 0) return pushGatewayService;
|
|
23765
|
+
return pushRuntimeReady === void 0 ? void 0 : pushRuntimeReady;
|
|
23766
|
+
};
|
|
23767
|
+
const requiredPushGatewayService = async () => {
|
|
23768
|
+
const service = await currentPushGatewayService();
|
|
23769
|
+
if (service === void 0) throw new Error("push_unavailable");
|
|
23770
|
+
return service;
|
|
23771
|
+
};
|
|
23772
|
+
const queueDeviceInvalidations = (scopeId, deviceIds) => {
|
|
23773
|
+
let queued = pendingDeviceInvalidations.get(scopeId);
|
|
23774
|
+
if (queued === void 0) pendingDeviceInvalidations.set(scopeId, queued = /* @__PURE__ */ new Set());
|
|
23775
|
+
for (const deviceId of deviceIds) queued.add(deviceId);
|
|
23776
|
+
};
|
|
23777
|
+
const flushDeviceInvalidations = async (service) => {
|
|
23778
|
+
while (pendingDeviceInvalidations.size > 0) {
|
|
23779
|
+
const batch = [...pendingDeviceInvalidations];
|
|
23780
|
+
pendingDeviceInvalidations.clear();
|
|
23781
|
+
for (const [scopeId, deviceIds] of batch) await service.invalidateDevices(scopeId, [...deviceIds]);
|
|
23782
|
+
}
|
|
23783
|
+
};
|
|
23784
|
+
const pushGatewayFacade = {
|
|
23785
|
+
attachOrigin(binding) {
|
|
23786
|
+
const id = Symbol("dsh-mobile push origin");
|
|
23787
|
+
const attachment = {
|
|
23788
|
+
binding,
|
|
23789
|
+
detach: void 0
|
|
23790
|
+
};
|
|
23791
|
+
pushOriginAttachments.set(id, attachment);
|
|
23792
|
+
try {
|
|
23793
|
+
if (pushGatewayService !== void 0) attachment.detach = pushGatewayService.attachOrigin(binding);
|
|
23794
|
+
} catch (error) {
|
|
23795
|
+
pushOriginAttachments.delete(id);
|
|
23796
|
+
throw error;
|
|
23797
|
+
}
|
|
23798
|
+
let active = true;
|
|
23799
|
+
return () => {
|
|
23800
|
+
if (!active) return;
|
|
23801
|
+
active = false;
|
|
23802
|
+
pushOriginAttachments.delete(id);
|
|
23803
|
+
try {
|
|
23804
|
+
attachment.detach?.();
|
|
23805
|
+
} finally {
|
|
23806
|
+
attachment.detach = void 0;
|
|
23807
|
+
}
|
|
23808
|
+
};
|
|
23809
|
+
},
|
|
23810
|
+
async invalidateDevices(scopeId, deviceIds) {
|
|
23811
|
+
if (pushGatewayService !== void 0) {
|
|
23812
|
+
await pushGatewayService.invalidateDevices(scopeId, deviceIds);
|
|
23813
|
+
return;
|
|
23814
|
+
}
|
|
23815
|
+
queueDeviceInvalidations(scopeId, deviceIds);
|
|
23816
|
+
},
|
|
23817
|
+
async reset() {
|
|
23818
|
+
await (await requiredPushGatewayService()).reset();
|
|
23819
|
+
},
|
|
23820
|
+
async config(owner, installationId) {
|
|
23821
|
+
const service = await currentPushGatewayService();
|
|
23822
|
+
return service === void 0 ? unavailablePushConfig(installationId) : service.config(owner, installationId);
|
|
23823
|
+
},
|
|
23824
|
+
async enroll(owner, input) {
|
|
23825
|
+
return (await requiredPushGatewayService()).enroll(owner, input);
|
|
23826
|
+
},
|
|
23827
|
+
async preferences(owner, installationId, value, guard) {
|
|
23828
|
+
return (await requiredPushGatewayService()).preferences(owner, installationId, value, guard);
|
|
23829
|
+
},
|
|
23830
|
+
async disable(owner, installationId, ifGeneration) {
|
|
23831
|
+
await (await requiredPushGatewayService()).disable(owner, installationId, ifGeneration);
|
|
23832
|
+
},
|
|
23833
|
+
async logout(owner) {
|
|
23834
|
+
const service = await currentPushGatewayService();
|
|
23835
|
+
if (service !== void 0) {
|
|
23836
|
+
await service.logout(owner);
|
|
23837
|
+
return;
|
|
23838
|
+
}
|
|
23839
|
+
if (activePushRuntimeGeneration !== void 0 || pushRegistry !== void 0) throw new Error("push_unavailable");
|
|
23840
|
+
const saved = await new JsonPushStore(join(stateDirectory, "mobile-push", "push-state.json")).load();
|
|
23841
|
+
if (activePushRuntimeGeneration !== void 0 || pushRegistry !== void 0 || saved.registrations.some((row) => row.enabled && row.scopeId === owner.scopeId && row.deviceId === owner.deviceId && row.origin === owner.origin)) throw new Error("push_unavailable");
|
|
23842
|
+
},
|
|
23843
|
+
async test(owner, installationId) {
|
|
23844
|
+
await (await requiredPushGatewayService()).test(owner, installationId);
|
|
23845
|
+
},
|
|
23846
|
+
async resolve(owner, reference) {
|
|
23847
|
+
return (await requiredPushGatewayService()).resolve(owner, reference);
|
|
23848
|
+
}
|
|
23849
|
+
};
|
|
23850
|
+
const closePushRuntime = async (coordinatorOverride) => {
|
|
23851
|
+
const coordinator = coordinatorOverride ?? pushGatewayService;
|
|
23852
|
+
if (pushGatewayService === coordinator) setPushGatewayService(void 0);
|
|
23853
|
+
else if (coordinator === void 0) settlePushRuntimeReadiness(void 0);
|
|
23854
|
+
const delivery = pushDelivery;
|
|
23855
|
+
pushDelivery = void 0;
|
|
23856
|
+
const registry = pushRegistry;
|
|
23857
|
+
pushRegistry = void 0;
|
|
23858
|
+
const host = pushHost;
|
|
23859
|
+
pushHost = void 0;
|
|
23860
|
+
await settleCleanupSteps([
|
|
23861
|
+
() => coordinator?.close(),
|
|
23862
|
+
() => delivery?.close(),
|
|
23863
|
+
() => registry?.close(),
|
|
23864
|
+
() => host?.close()
|
|
23865
|
+
]);
|
|
23866
|
+
};
|
|
23867
|
+
const initializePushRuntime = async (pushContext, isActive) => {
|
|
23868
|
+
try {
|
|
23869
|
+
pushHost = createPushHost(pushContext);
|
|
23870
|
+
if (!pushHost.available) {
|
|
23871
|
+
logger.info("declarative push unavailable: this DSH Host lacks the required public notification services");
|
|
23872
|
+
return;
|
|
23873
|
+
}
|
|
23874
|
+
pushRegistry = new PushRegistry(new JsonPushStore(join(stateDirectory, "mobile-push", "push-state.json")), createVapidKeys);
|
|
23875
|
+
await pushRegistry.initialize();
|
|
23876
|
+
if (!isActive()) return;
|
|
23877
|
+
pushDelivery = new PushDelivery({
|
|
23878
|
+
host: pushHost,
|
|
23879
|
+
registry: pushRegistry,
|
|
23880
|
+
sender: new PushSender()
|
|
23881
|
+
});
|
|
23882
|
+
await pushDelivery.start();
|
|
23883
|
+
if (!isActive()) return;
|
|
23884
|
+
const coordinator = new PushGatewayCoordinator(pushHost, pushRegistry, pushDelivery);
|
|
23885
|
+
await flushDeviceInvalidations(coordinator);
|
|
23886
|
+
if (!isActive()) return;
|
|
23887
|
+
setPushGatewayService(coordinator);
|
|
23888
|
+
} catch (error) {
|
|
23889
|
+
logger.warn("declarative push unavailable; Mobile access remains active (%s)", error instanceof Error ? error.name : "initialization-failed");
|
|
23890
|
+
if (isActive()) try {
|
|
23891
|
+
await closePushRuntime();
|
|
23892
|
+
} catch {
|
|
23893
|
+
logger.warn("declarative push cleanup failed; Mobile access remains active");
|
|
23894
|
+
}
|
|
23895
|
+
}
|
|
23896
|
+
};
|
|
23897
|
+
const startPushRuntimeGeneration = (pushContext) => {
|
|
23898
|
+
const generation = {
|
|
23899
|
+
active: true,
|
|
23900
|
+
ready: beginPushRuntimeReadiness(),
|
|
23901
|
+
initialization: Promise.resolve()
|
|
23902
|
+
};
|
|
23903
|
+
activePushRuntimeGeneration = generation;
|
|
23904
|
+
generation.initialization = (async () => {
|
|
23905
|
+
await initializePushRuntime(pushContext, () => generation.active);
|
|
23906
|
+
if (generation.active && pushGatewayService === void 0) settlePushRuntimeReadiness(void 0);
|
|
23907
|
+
})();
|
|
23908
|
+
return generation;
|
|
23909
|
+
};
|
|
23910
|
+
const closePushRuntimeGeneration = (generation) => {
|
|
23911
|
+
if (generation.closePromise !== void 0) return generation.closePromise;
|
|
23912
|
+
generation.active = false;
|
|
23913
|
+
if (activePushRuntimeGeneration !== generation) {
|
|
23914
|
+
generation.closePromise = generation.initialization;
|
|
23915
|
+
return generation.closePromise;
|
|
23916
|
+
}
|
|
23917
|
+
const coordinator = pushGatewayService;
|
|
23918
|
+
if (coordinator !== void 0) setPushGatewayService(void 0);
|
|
23919
|
+
else settlePushRuntimeReadiness(void 0);
|
|
23920
|
+
generation.closePromise = (async () => {
|
|
23921
|
+
await generation.initialization;
|
|
23922
|
+
if (activePushRuntimeGeneration !== generation) return;
|
|
23923
|
+
activePushRuntimeGeneration = void 0;
|
|
23924
|
+
await closePushRuntime(coordinator);
|
|
23925
|
+
})();
|
|
23926
|
+
return generation.closePromise;
|
|
23927
|
+
};
|
|
20287
23928
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
20288
23929
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
20289
23930
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -20354,8 +23995,17 @@ async function apply(ctx, config) {
|
|
|
20354
23995
|
let lanGateway;
|
|
20355
23996
|
let preparedLanSetup;
|
|
20356
23997
|
let trustedNetworksRestartRequired = false;
|
|
20357
|
-
let
|
|
23998
|
+
let lanConfigurationWork = Promise.resolve();
|
|
20358
23999
|
let adminClosing = false;
|
|
24000
|
+
const mutateLan = (operation) => {
|
|
24001
|
+
if (adminClosing) return Promise.reject(new HttpError(503, "unavailable"));
|
|
24002
|
+
const run = lanConfigurationWork.then(() => {
|
|
24003
|
+
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
24004
|
+
return operation();
|
|
24005
|
+
});
|
|
24006
|
+
lanConfigurationWork = run.then(() => {}, () => {});
|
|
24007
|
+
return run;
|
|
24008
|
+
};
|
|
20359
24009
|
const startGateway = async (candidateConfig) => {
|
|
20360
24010
|
const resolved = withInstallationIdentity(parseGatewayConfig({
|
|
20361
24011
|
...candidateConfig,
|
|
@@ -20371,7 +24021,7 @@ async function apply(ctx, config) {
|
|
|
20371
24021
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20372
24022
|
}, (entry) => {
|
|
20373
24023
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20374
|
-
});
|
|
24024
|
+
}, pushGatewayFacade);
|
|
20375
24025
|
await candidate.start();
|
|
20376
24026
|
lanGateway = candidate;
|
|
20377
24027
|
const removeTaskSink = taskEventHub.add(candidate);
|
|
@@ -20385,12 +24035,13 @@ async function apply(ctx, config) {
|
|
|
20385
24035
|
if (loaded.kind === "unconfigured") throw new Error(preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
20386
24036
|
if (loaded.kind === "fixed") return startGateway(loaded.config);
|
|
20387
24037
|
const following = new FollowingMobileAccessRuntime(async () => {
|
|
20388
|
-
const
|
|
24038
|
+
const setup = managedSetupOverride ?? loaded.setup;
|
|
24039
|
+
const network = selectLanNetwork(void 0, setup.networkInterface);
|
|
20389
24040
|
return {
|
|
20390
24041
|
key: `${network.name}\0${network.address}\0${network.cidr}`,
|
|
20391
24042
|
start: async () => startGateway({
|
|
20392
24043
|
...loaded.config,
|
|
20393
|
-
...await materializeManagedSetup(
|
|
24044
|
+
...await materializeManagedSetup(setup)
|
|
20394
24045
|
})
|
|
20395
24046
|
};
|
|
20396
24047
|
}, (error) => {
|
|
@@ -20435,7 +24086,7 @@ async function apply(ctx, config) {
|
|
|
20435
24086
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20436
24087
|
}, (entry) => {
|
|
20437
24088
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20438
|
-
});
|
|
24089
|
+
}, pushGatewayFacade);
|
|
20439
24090
|
await candidate.start();
|
|
20440
24091
|
return candidate;
|
|
20441
24092
|
};
|
|
@@ -20487,7 +24138,7 @@ async function apply(ctx, config) {
|
|
|
20487
24138
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20488
24139
|
}, (entry) => {
|
|
20489
24140
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20490
|
-
});
|
|
24141
|
+
}, pushGatewayFacade);
|
|
20491
24142
|
try {
|
|
20492
24143
|
await candidate.start();
|
|
20493
24144
|
} catch (error) {
|
|
@@ -20506,7 +24157,7 @@ async function apply(ctx, config) {
|
|
|
20506
24157
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20507
24158
|
}, (entry) => {
|
|
20508
24159
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20509
|
-
});
|
|
24160
|
+
}, pushGatewayFacade);
|
|
20510
24161
|
try {
|
|
20511
24162
|
await candidate.start();
|
|
20512
24163
|
} catch (error) {
|
|
@@ -20638,6 +24289,8 @@ async function apply(ctx, config) {
|
|
|
20638
24289
|
restartRequired: preparedLanSetup !== void 0
|
|
20639
24290
|
} : {},
|
|
20640
24291
|
running: lanController.isRunning(),
|
|
24292
|
+
rebindSupported: loaded.kind === "managed",
|
|
24293
|
+
...lanRebindRecoveryRequired ? { recoveryRequired: true } : {},
|
|
20641
24294
|
origin: lanGateway?.address().origin,
|
|
20642
24295
|
...preparedLanSetup === void 0 ? {} : { pendingOrigin: preparedLanSetup.origin },
|
|
20643
24296
|
...lanGateway === void 0 ? {} : { extensions: lanGateway.extensionStatus() }
|
|
@@ -20735,9 +24388,19 @@ async function apply(ctx, config) {
|
|
|
20735
24388
|
handler: async (request, response) => {
|
|
20736
24389
|
try {
|
|
20737
24390
|
const target = parseRequestTarget(request.url);
|
|
20738
|
-
|
|
24391
|
+
const pushReset = target.decodedPathname === `${LOCAL_ADMIN_PREFIX}/push/reset`;
|
|
24392
|
+
assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), pushReset ? [] : baseTemplate.adminAllowedCidrs);
|
|
20739
24393
|
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
20740
24394
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
24395
|
+
if (pushReset) {
|
|
24396
|
+
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
24397
|
+
const body = await readJsonObject(request, 1024);
|
|
24398
|
+
if (Object.keys(body).length !== 1 || body.confirm !== "reset-web-push") throw new HttpError(400, "bad_request");
|
|
24399
|
+
if (pushGatewayService === void 0) throw new HttpError(503, "push_unavailable");
|
|
24400
|
+
await pushGatewayService.reset();
|
|
24401
|
+
sendJson(response, 200, { reset: true }, false);
|
|
24402
|
+
return;
|
|
24403
|
+
}
|
|
20741
24404
|
const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
|
|
20742
24405
|
if (request.method === "GET" && lanControl) {
|
|
20743
24406
|
sendJson(response, 200, lanPayload(), false);
|
|
@@ -20790,13 +24453,10 @@ async function apply(ctx, config) {
|
|
|
20790
24453
|
} catch {
|
|
20791
24454
|
throw new HttpError(400, "lan_trusted_networks_invalid");
|
|
20792
24455
|
}
|
|
20793
|
-
|
|
20794
|
-
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
24456
|
+
await mutateLan(async () => {
|
|
20795
24457
|
await saveManagedTrustedNetworks(loaded.setupFile, values);
|
|
20796
24458
|
trustedNetworksRestartRequired = true;
|
|
20797
24459
|
});
|
|
20798
|
-
trustedNetworksWrite = write.catch(() => void 0);
|
|
20799
|
-
await write;
|
|
20800
24460
|
sendJson(response, 200, await trustedNetworksPayload(), false);
|
|
20801
24461
|
return;
|
|
20802
24462
|
}
|
|
@@ -20825,10 +24485,13 @@ async function apply(ctx, config) {
|
|
|
20825
24485
|
return;
|
|
20826
24486
|
}
|
|
20827
24487
|
if (request.method === "POST" && lanControl) {
|
|
20828
|
-
const
|
|
20829
|
-
if (typeof
|
|
20830
|
-
if (
|
|
20831
|
-
await
|
|
24488
|
+
const running = (await readJsonObject(request, 4096)).running;
|
|
24489
|
+
if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
|
|
24490
|
+
if (running && loaded.kind === "unconfigured") throw new HttpError(409, preparedLanSetup === void 0 ? "lan_setup_required" : "lan_setup_restart_required");
|
|
24491
|
+
await mutateLan(async () => {
|
|
24492
|
+
if (running && lanRebindRecoveryRequired) throw new HttpError(409, "lan_rebind_recovery_required");
|
|
24493
|
+
await lanController.setRunning(running);
|
|
24494
|
+
});
|
|
20832
24495
|
sendJson(response, 200, lanPayload(), false);
|
|
20833
24496
|
return;
|
|
20834
24497
|
}
|
|
@@ -20858,6 +24521,74 @@ async function apply(ctx, config) {
|
|
|
20858
24521
|
sendJson(response, 200, await lanSetupPayload(), false);
|
|
20859
24522
|
return;
|
|
20860
24523
|
}
|
|
24524
|
+
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/lan/networks`) {
|
|
24525
|
+
await lanConfigurationWork;
|
|
24526
|
+
const networks = availableLanNetworks();
|
|
24527
|
+
const preferred = await preferredLanInterfaceNames();
|
|
24528
|
+
let recommendedAddress;
|
|
24529
|
+
try {
|
|
24530
|
+
recommendedAddress = selectLanNetwork(void 0, void 0, void 0, preferred).address;
|
|
24531
|
+
} catch {}
|
|
24532
|
+
const currentInterface = loaded.kind === "managed" ? (managedSetupOverride ?? loaded.setup).networkInterface : void 0;
|
|
24533
|
+
sendJson(response, 200, {
|
|
24534
|
+
supported: loaded.kind === "managed",
|
|
24535
|
+
currentInterface,
|
|
24536
|
+
listenPort: loaded.kind === "managed" ? loaded.setup.listenPort : 3443,
|
|
24537
|
+
platform: process.platform,
|
|
24538
|
+
networks: networks.map((network) => ({
|
|
24539
|
+
name: network.name,
|
|
24540
|
+
address: network.address,
|
|
24541
|
+
cidr: network.cidr,
|
|
24542
|
+
current: network.name === currentInterface,
|
|
24543
|
+
recommended: network.address === recommendedAddress
|
|
24544
|
+
}))
|
|
24545
|
+
}, false);
|
|
24546
|
+
return;
|
|
24547
|
+
}
|
|
24548
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/lan/rebind`) {
|
|
24549
|
+
if (loaded.kind !== "managed") throw new HttpError(409, "lan_rebind_unsupported");
|
|
24550
|
+
const body = await readJsonObject(request, 4096);
|
|
24551
|
+
const networkInterface = typeof body.networkInterface === "string" ? body.networkInterface : "";
|
|
24552
|
+
if (networkInterface === "" || Object.keys(body).some((key) => key !== "networkInterface")) throw new HttpError(400, "bad_request");
|
|
24553
|
+
sendJson(response, 200, await mutateLan(async () => {
|
|
24554
|
+
let network;
|
|
24555
|
+
try {
|
|
24556
|
+
network = selectLanNetwork(void 0, networkInterface);
|
|
24557
|
+
} catch {
|
|
24558
|
+
throw new HttpError(409, "lan_rebind_network_unavailable");
|
|
24559
|
+
}
|
|
24560
|
+
const current = managedSetupOverride ?? loaded.setup;
|
|
24561
|
+
if (!lanRebindRecoveryRequired && current.networkInterface === networkInterface) throw new HttpError(409, "lan_rebind_unchanged");
|
|
24562
|
+
const previousOrigin = lanGateway?.address().origin;
|
|
24563
|
+
try {
|
|
24564
|
+
const result = await rebindManagedLan({
|
|
24565
|
+
setupFile: loaded.setupFile,
|
|
24566
|
+
controller: lanController,
|
|
24567
|
+
network,
|
|
24568
|
+
publish: (setup) => {
|
|
24569
|
+
managedSetupOverride = setup;
|
|
24570
|
+
}
|
|
24571
|
+
});
|
|
24572
|
+
trustedNetworksRestartRequired = false;
|
|
24573
|
+
lanRebindRecoveryRequired = false;
|
|
24574
|
+
return {
|
|
24575
|
+
origin: lanGateway?.address().origin ?? `https://${network.address}:${String(result.setup.listenPort)}`,
|
|
24576
|
+
previousOrigin,
|
|
24577
|
+
restartRequired: false,
|
|
24578
|
+
disconnected: result.running,
|
|
24579
|
+
ready: lanGateway !== void 0
|
|
24580
|
+
};
|
|
24581
|
+
} catch (error) {
|
|
24582
|
+
if (error instanceof ManagedLanConfigurationError) throw new HttpError(409, "lan_rebind_configuration_invalid");
|
|
24583
|
+
if (!(error instanceof MobileAccessReconfigurationError)) throw new HttpError(503, "lan_rebind_unavailable");
|
|
24584
|
+
const recovered = error.recovered;
|
|
24585
|
+
if (!recovered) lanRebindRecoveryRequired = true;
|
|
24586
|
+
logger.error("managed LAN rebind failed; prior configuration restored: %s", String(recovered));
|
|
24587
|
+
throw new HttpError(500, recovered ? "lan_rebind_failed" : "lan_rebind_recovery_required");
|
|
24588
|
+
}
|
|
24589
|
+
}), false);
|
|
24590
|
+
return;
|
|
24591
|
+
}
|
|
20861
24592
|
if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
|
|
20862
24593
|
sendJson(response, 200, remotePayload(), false);
|
|
20863
24594
|
return;
|
|
@@ -21340,7 +25071,20 @@ async function apply(ctx, config) {
|
|
|
21340
25071
|
};
|
|
21341
25072
|
}
|
|
21342
25073
|
});
|
|
25074
|
+
let disposePushRuntimeInjection;
|
|
21343
25075
|
try {
|
|
25076
|
+
const pushHostPresent = pushHostServices.every((service) => ctx.get(service) !== void 0);
|
|
25077
|
+
if (pushHostPresent) beginPushRuntimeReadiness();
|
|
25078
|
+
const pushRuntimeFiber = ctx.inject(pushHostServices, (pushContext) => {
|
|
25079
|
+
const generation = startPushRuntimeGeneration(pushContext);
|
|
25080
|
+
return () => closePushRuntimeGeneration(generation);
|
|
25081
|
+
});
|
|
25082
|
+
disposePushRuntimeInjection = () => pushRuntimeFiber.dispose();
|
|
25083
|
+
if (pushHostPresent) {
|
|
25084
|
+
await pushRuntimeFiber.await();
|
|
25085
|
+
const generation = activePushRuntimeGeneration;
|
|
25086
|
+
if (generation !== void 0) await generation.ready;
|
|
25087
|
+
}
|
|
21344
25088
|
await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
|
|
21345
25089
|
await lanController.initialize();
|
|
21346
25090
|
const stores = {
|
|
@@ -21363,7 +25107,7 @@ async function apply(ctx, config) {
|
|
|
21363
25107
|
stopAdmin,
|
|
21364
25108
|
drainRemoteWork,
|
|
21365
25109
|
() => releaseManager.dispose(),
|
|
21366
|
-
() =>
|
|
25110
|
+
() => lanConfigurationWork,
|
|
21367
25111
|
disposeMobileCommand,
|
|
21368
25112
|
disposeTaskEvents,
|
|
21369
25113
|
async () => {
|
|
@@ -21371,6 +25115,8 @@ async function apply(ctx, config) {
|
|
|
21371
25115
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
21372
25116
|
},
|
|
21373
25117
|
() => lanController.close(),
|
|
25118
|
+
() => disposePushRuntimeInjection?.(),
|
|
25119
|
+
closePushRuntime,
|
|
21374
25120
|
() => mobileAccess.stopLocal(),
|
|
21375
25121
|
unregisterBuiltin
|
|
21376
25122
|
]);
|
|
@@ -21384,7 +25130,7 @@ async function apply(ctx, config) {
|
|
|
21384
25130
|
stopAdmin,
|
|
21385
25131
|
drainRemoteWork,
|
|
21386
25132
|
() => releaseManager.dispose(),
|
|
21387
|
-
() =>
|
|
25133
|
+
() => lanConfigurationWork,
|
|
21388
25134
|
disposeMobileCommand,
|
|
21389
25135
|
disposeTaskEvents,
|
|
21390
25136
|
async () => {
|
|
@@ -21392,6 +25138,8 @@ async function apply(ctx, config) {
|
|
|
21392
25138
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
21393
25139
|
},
|
|
21394
25140
|
() => lanController.close(),
|
|
25141
|
+
() => disposePushRuntimeInjection?.(),
|
|
25142
|
+
closePushRuntime,
|
|
21395
25143
|
() => mobileAccess.stopLocal(),
|
|
21396
25144
|
unregisterBuiltin
|
|
21397
25145
|
]);
|