dsh-mobile 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTORS.md +5 -2
- package/README.en.md +58 -25
- package/README.md +58 -25
- package/SECURITY.md +13 -0
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/apps/mobile/README.md +11 -7
- package/apps/mobile/README.zh-CN.md +11 -7
- 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/CADDY_MANAGED.en.md +1 -1
- package/docs/CADDY_MANAGED.md +1 -1
- package/docs/CLOUDFLARE_TUNNEL.en.md +5 -5
- package/docs/CLOUDFLARE_TUNNEL.md +5 -5
- package/docs/CPOLAR_SUBDOMAIN.en.md +2 -2
- package/docs/CPOLAR_SUBDOMAIN.md +2 -2
- package/docs/IROH.en.md +24 -3
- package/docs/IROH.md +24 -3
- package/docs/README.md +3 -1
- package/docs/SELF_HOSTED_ORIGIN.en.md +2 -2
- package/docs/SELF_HOSTED_ORIGIN.md +3 -3
- 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/client.js +1919 -243
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +126 -1
- package/lib/index.mjs +3787 -96
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js +15 -2
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +17 -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),
|
|
@@ -2041,6 +2088,7 @@ function requestPathCategory(path) {
|
|
|
2041
2088
|
default:
|
|
2042
2089
|
if (path.startsWith("/api/")) return "/api/*";
|
|
2043
2090
|
if (path.startsWith("/mobile-access/extensions/")) return "/mobile-access/extensions/*";
|
|
2091
|
+
if (/^\/mobile-access\/compat-[a-f\d]{64}\.js$/u.test(path)) return "/mobile-access/compat.js";
|
|
2044
2092
|
if (path.startsWith("/mobile-access/")) return "/mobile-access/*";
|
|
2045
2093
|
return "/*";
|
|
2046
2094
|
}
|
|
@@ -2518,6 +2566,22 @@ function renderClientModuleRecoveryScript(locale) {
|
|
|
2518
2566
|
//#region src/mobile-compat-bootstrap.ts
|
|
2519
2567
|
const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
|
|
2520
2568
|
/**
|
|
2569
|
+
* Name the compatibility bundle by content.
|
|
2570
|
+
*
|
|
2571
|
+
* The plain path has no revision, so a client that stores it must revalidate on
|
|
2572
|
+
* every page load. A content-addressed path can be served `immutable` instead: the
|
|
2573
|
+
* URL changes exactly when the bundle does, and a stale URL is refused rather than
|
|
2574
|
+
* answered with different bytes.
|
|
2575
|
+
*/
|
|
2576
|
+
function mobileCompatibilityRevisionPath(digest) {
|
|
2577
|
+
return `${AUTH_PREFIX}/compat-${digest}.js`;
|
|
2578
|
+
}
|
|
2579
|
+
const MOBILE_COMPAT_REVISION = new RegExp(`^${AUTH_PREFIX}/compat-([a-f\\d]{64})\\.js$`, "u");
|
|
2580
|
+
/** The content digest named by a revisioned compatibility path, if this is one. */
|
|
2581
|
+
function mobileCompatibilityRevision(pathname) {
|
|
2582
|
+
return MOBILE_COMPAT_REVISION.exec(pathname)?.[1];
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2521
2585
|
* Read one attribute out of a tag body.
|
|
2522
2586
|
*
|
|
2523
2587
|
* A plain `/\snonce\s*=/` search can be fooled by a value that merely contains the
|
|
@@ -2550,17 +2614,19 @@ function ensureMobileGatewayMarker(html) {
|
|
|
2550
2614
|
* Returns the HTML unchanged when there is no executable script to anchor to.
|
|
2551
2615
|
* The bundle supplies missing browser APIs before the page's scripts execute.
|
|
2552
2616
|
*/
|
|
2553
|
-
function ensureMobileCompatibility(html) {
|
|
2617
|
+
function ensureMobileCompatibility(html, compatPath = MOBILE_COMPAT_PATH) {
|
|
2554
2618
|
const comments = commentRanges(html);
|
|
2555
2619
|
const isInert = (at) => comments.some(([start, end]) => at >= start && at < end);
|
|
2556
2620
|
const scripts = [...html.matchAll(/<script\b[^>]*>/giu)].filter((match) => !isInert(match.index));
|
|
2557
2621
|
if (scripts.length === 0) return html;
|
|
2558
|
-
if (scripts.some((match) =>
|
|
2622
|
+
if (scripts.some((match) => {
|
|
2623
|
+
const src = attributeValue(match[0], "src");
|
|
2624
|
+
return src !== void 0 && (src === MOBILE_COMPAT_PATH || mobileCompatibilityRevision(src) !== void 0);
|
|
2625
|
+
})) return html;
|
|
2559
2626
|
const first = scripts[0];
|
|
2560
2627
|
if (first === void 0) return html;
|
|
2561
2628
|
const nonce = attributeValue(first[0], "nonce")?.replace(/["'<>\s]/gu, "") ?? "";
|
|
2562
|
-
const
|
|
2563
|
-
const script = `<script src="${MOBILE_COMPAT_PATH}"${attribute}><\/script>`;
|
|
2629
|
+
const script = `<script src="${compatPath}"${nonce === "" ? "" : ` nonce="${nonce}"`}><\/script>`;
|
|
2564
2630
|
return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
|
|
2565
2631
|
}
|
|
2566
2632
|
/** Maximum sizes enforced when reading local extension files. */
|
|
@@ -5209,6 +5275,67 @@ function resolveBrowserUiLocale(cookieHeader, acceptLanguage) {
|
|
|
5209
5275
|
return readBrowserUiLocale(cookieHeader) ?? resolveAuthPageLocale(acceptLanguage);
|
|
5210
5276
|
}
|
|
5211
5277
|
//#endregion
|
|
5278
|
+
//#region src/mobile-push/assets.ts
|
|
5279
|
+
const ICON_FILES = Object.freeze({
|
|
5280
|
+
"/mobile-access/push/icon-180.png": "icon-180.png",
|
|
5281
|
+
"/mobile-access/push/icon-192.png": "icon-192.png",
|
|
5282
|
+
"/mobile-access/push/icon-512.png": "icon-512.png"
|
|
5283
|
+
});
|
|
5284
|
+
const MANIFEST_PATH = "/mobile-access/push/manifest.webmanifest";
|
|
5285
|
+
const MANIFEST = JSON.stringify({
|
|
5286
|
+
id: "/",
|
|
5287
|
+
start_url: "/",
|
|
5288
|
+
scope: "/",
|
|
5289
|
+
display: "standalone",
|
|
5290
|
+
icons: [
|
|
5291
|
+
{
|
|
5292
|
+
src: "/mobile-access/push/icon-180.png",
|
|
5293
|
+
sizes: "180x180",
|
|
5294
|
+
type: "image/png"
|
|
5295
|
+
},
|
|
5296
|
+
{
|
|
5297
|
+
src: "/mobile-access/push/icon-192.png",
|
|
5298
|
+
sizes: "192x192",
|
|
5299
|
+
type: "image/png"
|
|
5300
|
+
},
|
|
5301
|
+
{
|
|
5302
|
+
src: "/mobile-access/push/icon-512.png",
|
|
5303
|
+
sizes: "512x512",
|
|
5304
|
+
type: "image/png"
|
|
5305
|
+
}
|
|
5306
|
+
]
|
|
5307
|
+
});
|
|
5308
|
+
/** Create a fixed-allowlist loader rooted at a package root containing assets/mobile-push. */
|
|
5309
|
+
function createMobilePushAssetLoader(packageRoot) {
|
|
5310
|
+
const root = resolve(packageRoot);
|
|
5311
|
+
return async (path) => {
|
|
5312
|
+
if (path === MANIFEST_PATH) return {
|
|
5313
|
+
contentType: "application/manifest+json; charset=utf-8",
|
|
5314
|
+
body: MANIFEST
|
|
5315
|
+
};
|
|
5316
|
+
const filename = ICON_FILES[path];
|
|
5317
|
+
if (!filename) return void 0;
|
|
5318
|
+
return {
|
|
5319
|
+
contentType: "image/png",
|
|
5320
|
+
body: await readFile(join(root, "assets", "mobile-push", filename))
|
|
5321
|
+
};
|
|
5322
|
+
};
|
|
5323
|
+
}
|
|
5324
|
+
const packageRoot = dirname(createRequire(import.meta.url).resolve("dsh-mobile/package.json"));
|
|
5325
|
+
const loadFromPackage = createMobilePushAssetLoader(packageRoot);
|
|
5326
|
+
/** Return only a known push manifest/icon; optional packageRoot enables explicit host injection. */
|
|
5327
|
+
function mobilePushAsset(path, root = packageRoot) {
|
|
5328
|
+
return root === packageRoot ? loadFromPackage(path) : createMobilePushAssetLoader(root)(path);
|
|
5329
|
+
}
|
|
5330
|
+
//#endregion
|
|
5331
|
+
//#region src/mobile-push/installation-id.ts
|
|
5332
|
+
/** Browser-safe installation-id validation shared by the browser controller and the Gateway. */
|
|
5333
|
+
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;
|
|
5334
|
+
/** True only for a UUID-shaped installation id with an allowed version and variant nibble. */
|
|
5335
|
+
function isPushInstallationId(value) {
|
|
5336
|
+
return typeof value === "string" && INSTALLATION_ID.test(value);
|
|
5337
|
+
}
|
|
5338
|
+
//#endregion
|
|
5212
5339
|
//#region src/gateway.ts
|
|
5213
5340
|
const MAX_CONTROL_BODY_BYTES = 16384;
|
|
5214
5341
|
const MAX_HEADER_BYTES = 16384;
|
|
@@ -5220,6 +5347,93 @@ const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
|
|
|
5220
5347
|
const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
|
|
5221
5348
|
const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
|
|
5222
5349
|
const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
|
|
5350
|
+
const MOBILE_PUSH_CONFIG_PATH = `${AUTH_PREFIX}/push/config`;
|
|
5351
|
+
const MOBILE_PUSH_SUBSCRIPTION_PATH = `${AUTH_PREFIX}/push/subscription`;
|
|
5352
|
+
const MOBILE_PUSH_PREFERENCES_PATH = `${AUTH_PREFIX}/push/preferences`;
|
|
5353
|
+
const MOBILE_PUSH_DISABLE_PATH = `${AUTH_PREFIX}/push/disable`;
|
|
5354
|
+
const MOBILE_PUSH_TEST_PATH = `${AUTH_PREFIX}/push/test`;
|
|
5355
|
+
const MOBILE_PUSH_RESOLVE_PATH = `${AUTH_PREFIX}/push/resolve`;
|
|
5356
|
+
const MOBILE_PUSH_PREFERENCE_KEYS = [
|
|
5357
|
+
"question",
|
|
5358
|
+
"plan",
|
|
5359
|
+
"approval",
|
|
5360
|
+
"response",
|
|
5361
|
+
"goal",
|
|
5362
|
+
"failure"
|
|
5363
|
+
];
|
|
5364
|
+
const DEFAULT_PUSH_PREFERENCES = Object.freeze({
|
|
5365
|
+
question: true,
|
|
5366
|
+
plan: true,
|
|
5367
|
+
approval: true,
|
|
5368
|
+
response: true,
|
|
5369
|
+
goal: true,
|
|
5370
|
+
failure: true
|
|
5371
|
+
});
|
|
5372
|
+
function isRecord$1(value) {
|
|
5373
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5374
|
+
}
|
|
5375
|
+
function hasOnlyKeys(value, keys) {
|
|
5376
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
5377
|
+
}
|
|
5378
|
+
function exactKeys$1(value, keys) {
|
|
5379
|
+
return Object.keys(value).length === keys.length && hasOnlyKeys(value, keys);
|
|
5380
|
+
}
|
|
5381
|
+
function parseInstallationId(value) {
|
|
5382
|
+
if (!isPushInstallationId(value)) throw new HttpError(400, "bad_request");
|
|
5383
|
+
return value;
|
|
5384
|
+
}
|
|
5385
|
+
function parsePushPreferences(value) {
|
|
5386
|
+
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");
|
|
5387
|
+
return Object.freeze({
|
|
5388
|
+
question: value.question,
|
|
5389
|
+
plan: value.plan,
|
|
5390
|
+
approval: value.approval,
|
|
5391
|
+
response: value.response,
|
|
5392
|
+
goal: value.goal,
|
|
5393
|
+
failure: value.failure
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
function parseGeneration(value, allowZero) {
|
|
5397
|
+
if (!Number.isSafeInteger(value) || Number(value) < (allowZero ? 0 : 1)) throw new HttpError(400, "bad_request");
|
|
5398
|
+
return Number(value);
|
|
5399
|
+
}
|
|
5400
|
+
function parsePushEnrollment(value) {
|
|
5401
|
+
if (!exactKeys$1(value, [
|
|
5402
|
+
"installationId",
|
|
5403
|
+
"subscription",
|
|
5404
|
+
"locale",
|
|
5405
|
+
"preferences",
|
|
5406
|
+
"keyFingerprint",
|
|
5407
|
+
"ownerGeneration",
|
|
5408
|
+
"registrationGeneration"
|
|
5409
|
+
]) || !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");
|
|
5410
|
+
const installationId = parseInstallationId(value.installationId);
|
|
5411
|
+
const preferences = parsePushPreferences(value.preferences);
|
|
5412
|
+
const subscription = value.subscription;
|
|
5413
|
+
if (typeof subscription.endpoint !== "string" || typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") throw new HttpError(400, "bad_request");
|
|
5414
|
+
return {
|
|
5415
|
+
installationId,
|
|
5416
|
+
subscription,
|
|
5417
|
+
locale: value.locale,
|
|
5418
|
+
preferences,
|
|
5419
|
+
keyFingerprint: value.keyFingerprint,
|
|
5420
|
+
ownerGeneration: parseGeneration(value.ownerGeneration, true),
|
|
5421
|
+
registrationGeneration: parseGeneration(value.registrationGeneration, true)
|
|
5422
|
+
};
|
|
5423
|
+
}
|
|
5424
|
+
function safePushFailure(error) {
|
|
5425
|
+
if (error instanceof HttpError) return error;
|
|
5426
|
+
const message = error instanceof Error ? error.message : "";
|
|
5427
|
+
if (message.includes("stale push mutation generation")) return new HttpError(409, "stale_generation");
|
|
5428
|
+
if (message.includes("conflict") || message.includes("already owned")) return new HttpError(409, "push_conflict");
|
|
5429
|
+
if (message === "push_unauthorized") return new HttpError(401, "authentication_failed");
|
|
5430
|
+
if (message === "push_test_rate_limited") return new HttpError(429, "rate_limited");
|
|
5431
|
+
if (message === "push_test_unavailable") return new HttpError(503, "push_unavailable");
|
|
5432
|
+
if (message === "push_subscription_invalid" || message === "push_endpoint_invalid" || message.startsWith("invalid subscription")) return new HttpError(400, "bad_request");
|
|
5433
|
+
if (message === "push_unavailable") return new HttpError(503, "push_unavailable");
|
|
5434
|
+
if (message === "push_test_failed") return new HttpError(502, "push_test_failed");
|
|
5435
|
+
return new HttpError(500, "internal_error");
|
|
5436
|
+
}
|
|
5223
5437
|
const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
|
|
5224
5438
|
const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
|
|
5225
5439
|
/**
|
|
@@ -5674,6 +5888,30 @@ function rewriteMobileApplicationPreloads(html, replacements) {
|
|
|
5674
5888
|
return escaped.get(preloadAttribute(tag, "href") ?? "") ?? tag;
|
|
5675
5889
|
});
|
|
5676
5890
|
}
|
|
5891
|
+
const MOBILE_PUSH_HEAD_LINKS = [
|
|
5892
|
+
"<link rel=\"manifest\" href=\"/mobile-access/push/manifest.webmanifest\">",
|
|
5893
|
+
"<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/mobile-access/push/icon-180.png\">",
|
|
5894
|
+
"<link rel=\"apple-touch-icon\" sizes=\"192x192\" href=\"/mobile-access/push/icon-192.png\">",
|
|
5895
|
+
"<link rel=\"apple-touch-icon\" sizes=\"512x512\" href=\"/mobile-access/push/icon-512.png\">"
|
|
5896
|
+
].join("");
|
|
5897
|
+
function replaceDedicatedMobilePushLinks(html) {
|
|
5898
|
+
if (/<head\b[^>]*>/iu.exec(html) === null) return html;
|
|
5899
|
+
const linkPattern = /<link\b[^>]*>/giu;
|
|
5900
|
+
const remove = [];
|
|
5901
|
+
for (const match of html.matchAll(linkPattern)) {
|
|
5902
|
+
const rel = (preloadAttribute(match[0], "rel") ?? "").toLowerCase().split(/\s+/u);
|
|
5903
|
+
if (rel.includes("manifest") || rel.includes("apple-touch-icon")) remove.push({
|
|
5904
|
+
start: match.index,
|
|
5905
|
+
end: match.index + match[0].length
|
|
5906
|
+
});
|
|
5907
|
+
}
|
|
5908
|
+
let cleaned = html;
|
|
5909
|
+
for (const range of remove.reverse()) cleaned = `${cleaned.slice(0, range.start)}${cleaned.slice(range.end)}`;
|
|
5910
|
+
const head = /<head\b[^>]*>/iu.exec(cleaned);
|
|
5911
|
+
if (head?.index === void 0) throw new Error("dedicated mobile document has no head element");
|
|
5912
|
+
const position = head.index + head[0].length;
|
|
5913
|
+
return `${cleaned.slice(0, position)}${MOBILE_PUSH_HEAD_LINKS}${cleaned.slice(position)}`;
|
|
5914
|
+
}
|
|
5677
5915
|
function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
5678
5916
|
const plan = parseMobileBootPlan(html, options.excludedClientModules);
|
|
5679
5917
|
const projected = projectMobileBootGraph(plan, options);
|
|
@@ -5681,7 +5919,7 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
|
|
|
5681
5919
|
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
5920
|
const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
|
|
5683
5921
|
return Object.freeze({
|
|
5684
|
-
html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements)))),
|
|
5922
|
+
html: replaceDedicatedMobilePushLinks(ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements), options.compatPath)))),
|
|
5685
5923
|
batches: projected.batches,
|
|
5686
5924
|
graphTransport: projected.graphTransport
|
|
5687
5925
|
});
|
|
@@ -6141,6 +6379,7 @@ var MobileAccessGateway = class {
|
|
|
6141
6379
|
extraWebSocketPaths;
|
|
6142
6380
|
blockedUpgradeLog;
|
|
6143
6381
|
onDiscoveryDegraded;
|
|
6382
|
+
pushGateway;
|
|
6144
6383
|
access;
|
|
6145
6384
|
listenerTlsEnabled;
|
|
6146
6385
|
tlsEnabled;
|
|
@@ -6188,6 +6427,10 @@ var MobileAccessGateway = class {
|
|
|
6188
6427
|
started = false;
|
|
6189
6428
|
closeTask;
|
|
6190
6429
|
removeSessionListener;
|
|
6430
|
+
removePushDeviceListener;
|
|
6431
|
+
detachPushOrigin;
|
|
6432
|
+
pushDeviceRevocations = /* @__PURE__ */ new Set();
|
|
6433
|
+
pushTestsInFlight = /* @__PURE__ */ new Set();
|
|
6191
6434
|
removeExtensionContentListener;
|
|
6192
6435
|
renewFailureLimiter;
|
|
6193
6436
|
renewDeviceLimiter;
|
|
@@ -6207,13 +6450,14 @@ var MobileAccessGateway = class {
|
|
|
6207
6450
|
httpFailures;
|
|
6208
6451
|
apiLatency;
|
|
6209
6452
|
upstreamAuthDiagnostics;
|
|
6210
|
-
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog) {
|
|
6453
|
+
constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog, apiLatencyLog, pushGateway) {
|
|
6211
6454
|
this.config = config;
|
|
6212
6455
|
this.extensions = extensions;
|
|
6213
6456
|
this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
|
|
6214
6457
|
this.extraWebSocketPaths = extraWebSocketPaths;
|
|
6215
6458
|
this.blockedUpgradeLog = blockedUpgradeLog;
|
|
6216
6459
|
this.onDiscoveryDegraded = onDiscoveryDegraded;
|
|
6460
|
+
this.pushGateway = pushGateway;
|
|
6217
6461
|
this.authFailures = new AuthFailureRecorder(authFailureLog);
|
|
6218
6462
|
this.httpFailures = new HttpFailureRecorder(httpFailureLog);
|
|
6219
6463
|
this.apiLatency = new ApiLatencyRecorder(apiLatencyLog);
|
|
@@ -6239,6 +6483,15 @@ var MobileAccessGateway = class {
|
|
|
6239
6483
|
this.renewDeviceLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxDevices);
|
|
6240
6484
|
this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
|
|
6241
6485
|
this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
|
|
6486
|
+
this.removePushDeviceListener = this.access.onDevicesRemoved((ids) => {
|
|
6487
|
+
const invalidate = this.pushGateway?.invalidateDevices;
|
|
6488
|
+
if (invalidate === void 0) return;
|
|
6489
|
+
const mutation = Promise.resolve().then(() => invalidate(config.stateFile, ids)).catch(() => {
|
|
6490
|
+
process.emitWarning("Unable to remove revoked device notification state", { code: "DSH_MOBILE_PUSH_REVOKE_FAILED" });
|
|
6491
|
+
});
|
|
6492
|
+
this.pushDeviceRevocations.add(mutation);
|
|
6493
|
+
mutation.finally(() => this.pushDeviceRevocations.delete(mutation));
|
|
6494
|
+
});
|
|
6242
6495
|
this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
|
|
6243
6496
|
for (const view of this.mobileDocumentViews.values()) if (view.sessionKey === authorization.sessionKey) this.removeMobileDocumentView(view);
|
|
6244
6497
|
if (reason === "revoked") {
|
|
@@ -6334,6 +6587,15 @@ var MobileAccessGateway = class {
|
|
|
6334
6587
|
if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
|
|
6335
6588
|
this.listenerPort = address.port;
|
|
6336
6589
|
this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
|
|
6590
|
+
if (this.pushGateway?.attachOrigin !== void 0) try {
|
|
6591
|
+
this.detachPushOrigin = this.pushGateway.attachOrigin({
|
|
6592
|
+
scopeId: this.config.stateFile,
|
|
6593
|
+
origin: this.address().origin,
|
|
6594
|
+
authorizedDevices: () => this.access.authorizedDevices()
|
|
6595
|
+
});
|
|
6596
|
+
} catch {
|
|
6597
|
+
process.emitWarning("Mobile push origin attachment failed; Mobile access remains available", { code: "DSH_MOBILE_PUSH_ORIGIN_ATTACH_FAILED" });
|
|
6598
|
+
}
|
|
6337
6599
|
if (this.config.discovery) await this.startDiscovery(address.port);
|
|
6338
6600
|
await this.pollLegacyCustomChanges();
|
|
6339
6601
|
this.extensionChangeTimer = setInterval(() => {
|
|
@@ -6465,6 +6727,10 @@ var MobileAccessGateway = class {
|
|
|
6465
6727
|
}), "utf8");
|
|
6466
6728
|
}
|
|
6467
6729
|
async closeFailedStart() {
|
|
6730
|
+
const detachPushOrigin = this.detachPushOrigin;
|
|
6731
|
+
this.detachPushOrigin = void 0;
|
|
6732
|
+
detachPushOrigin?.();
|
|
6733
|
+
this.removePushDeviceListener();
|
|
6468
6734
|
if (this.extensionChangeTimer !== void 0) clearInterval(this.extensionChangeTimer);
|
|
6469
6735
|
this.extensionChangeTimer = void 0;
|
|
6470
6736
|
this.removeExtensionContentListener();
|
|
@@ -6696,10 +6962,111 @@ var MobileAccessGateway = class {
|
|
|
6696
6962
|
}, this.tlsEnabled);
|
|
6697
6963
|
});
|
|
6698
6964
|
}
|
|
6965
|
+
async requireFreshPushOwner(authorization) {
|
|
6966
|
+
const owner = {
|
|
6967
|
+
scopeId: this.config.stateFile,
|
|
6968
|
+
deviceId: authorization.deviceId,
|
|
6969
|
+
origin: this.address().origin
|
|
6970
|
+
};
|
|
6971
|
+
if (!(await this.access.authorizedDevices()).some((device) => device.id === owner.deviceId && device.expiresAt > Date.now())) throw new HttpError(401, "authentication_failed");
|
|
6972
|
+
return owner;
|
|
6973
|
+
}
|
|
6974
|
+
async handleMobilePushRequest(target, request, response, authorization) {
|
|
6975
|
+
const method = request.method ?? "";
|
|
6976
|
+
if (target.decodedPathname === MOBILE_PUSH_CONFIG_PATH) {
|
|
6977
|
+
if (method !== "GET") throw new HttpError(405, "method_not_allowed");
|
|
6978
|
+
const params = new URLSearchParams(target.search);
|
|
6979
|
+
const values = params.getAll("installationId");
|
|
6980
|
+
if (params.size !== 1 || values.length !== 1) throw new HttpError(400, "bad_request");
|
|
6981
|
+
const installationId = parseInstallationId(values[0]);
|
|
6982
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
6983
|
+
sendJson(response, 200, this.pushGateway === void 0 ? {
|
|
6984
|
+
available: false,
|
|
6985
|
+
installationId,
|
|
6986
|
+
ownerGeneration: 0,
|
|
6987
|
+
registrationGeneration: 0,
|
|
6988
|
+
status: "disabled",
|
|
6989
|
+
preferences: DEFAULT_PUSH_PREFERENCES
|
|
6990
|
+
} : await this.pushGateway.config(owner, installationId).catch((error) => {
|
|
6991
|
+
throw safePushFailure(error);
|
|
6992
|
+
}), this.tlsEnabled);
|
|
6993
|
+
return;
|
|
6994
|
+
}
|
|
6995
|
+
if (method !== "POST" || target.search !== "") throw new HttpError(method === "POST" ? 400 : 405, method === "POST" ? "bad_request" : "method_not_allowed");
|
|
6996
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
6997
|
+
const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
6998
|
+
const service = this.pushGateway;
|
|
6999
|
+
if (service === void 0) throw new HttpError(503, "push_unavailable");
|
|
7000
|
+
try {
|
|
7001
|
+
if (target.decodedPathname === MOBILE_PUSH_SUBSCRIPTION_PATH) {
|
|
7002
|
+
const enrollment = parsePushEnrollment(body);
|
|
7003
|
+
sendJson(response, 200, await service.enroll(owner, enrollment), this.tlsEnabled);
|
|
7004
|
+
return;
|
|
7005
|
+
}
|
|
7006
|
+
if (target.decodedPathname === MOBILE_PUSH_PREFERENCES_PATH) {
|
|
7007
|
+
if (!exactKeys$1(body, [
|
|
7008
|
+
"installationId",
|
|
7009
|
+
"preferences",
|
|
7010
|
+
"ownerGeneration",
|
|
7011
|
+
"registrationGeneration"
|
|
7012
|
+
])) throw new HttpError(400, "bad_request");
|
|
7013
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7014
|
+
const preferences = parsePushPreferences(body.preferences);
|
|
7015
|
+
const guard = {
|
|
7016
|
+
ownerGeneration: parseGeneration(body.ownerGeneration, true),
|
|
7017
|
+
registrationGeneration: parseGeneration(body.registrationGeneration, true)
|
|
7018
|
+
};
|
|
7019
|
+
sendJson(response, 200, await service.preferences(owner, installationId, preferences, guard), this.tlsEnabled);
|
|
7020
|
+
return;
|
|
7021
|
+
}
|
|
7022
|
+
if (target.decodedPathname === MOBILE_PUSH_DISABLE_PATH) {
|
|
7023
|
+
if (!exactKeys$1(body, body.ifGeneration === void 0 ? ["installationId"] : ["installationId", "ifGeneration"])) throw new HttpError(400, "bad_request");
|
|
7024
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7025
|
+
const ifGeneration = body.ifGeneration === void 0 ? void 0 : parseGeneration(body.ifGeneration, false);
|
|
7026
|
+
await service.disable(owner, installationId, ifGeneration);
|
|
7027
|
+
sendJson(response, 200, { disabled: true }, this.tlsEnabled);
|
|
7028
|
+
return;
|
|
7029
|
+
}
|
|
7030
|
+
if (target.decodedPathname === MOBILE_PUSH_TEST_PATH) {
|
|
7031
|
+
if (!exactKeys$1(body, ["installationId"])) throw new HttpError(400, "bad_request");
|
|
7032
|
+
const installationId = parseInstallationId(body.installationId);
|
|
7033
|
+
const key = JSON.stringify([
|
|
7034
|
+
owner.scopeId,
|
|
7035
|
+
owner.deviceId,
|
|
7036
|
+
owner.origin,
|
|
7037
|
+
installationId
|
|
7038
|
+
]);
|
|
7039
|
+
if (this.pushTestsInFlight.has(key)) throw new HttpError(429, "rate_limited");
|
|
7040
|
+
this.pushTestsInFlight.add(key);
|
|
7041
|
+
try {
|
|
7042
|
+
await service.test(owner, installationId);
|
|
7043
|
+
} finally {
|
|
7044
|
+
this.pushTestsInFlight.delete(key);
|
|
7045
|
+
}
|
|
7046
|
+
sendJson(response, 200, { accepted: true }, this.tlsEnabled);
|
|
7047
|
+
return;
|
|
7048
|
+
}
|
|
7049
|
+
if (target.decodedPathname === MOBILE_PUSH_RESOLVE_PATH) {
|
|
7050
|
+
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");
|
|
7051
|
+
const resolved = await service.resolve(owner, body.reference);
|
|
7052
|
+
if (resolved === void 0) throw new HttpError(404, "not_found");
|
|
7053
|
+
sendJson(response, 200, resolved, this.tlsEnabled);
|
|
7054
|
+
return;
|
|
7055
|
+
}
|
|
7056
|
+
throw new HttpError(404, "not_found");
|
|
7057
|
+
} catch (error) {
|
|
7058
|
+
if (error instanceof HttpError) throw error;
|
|
7059
|
+
throw safePushFailure(error);
|
|
7060
|
+
}
|
|
7061
|
+
}
|
|
6699
7062
|
async handleLogout(request, response) {
|
|
6700
7063
|
await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
|
|
6701
7064
|
const authorization = this.authorize(request);
|
|
6702
7065
|
this.requireCsrf(request, authorization);
|
|
7066
|
+
const owner = await this.requireFreshPushOwner(authorization);
|
|
7067
|
+
if (this.pushGateway !== void 0) await this.pushGateway.logout(owner).catch((error) => {
|
|
7068
|
+
throw safePushFailure(error);
|
|
7069
|
+
});
|
|
6703
7070
|
this.access.logout(authorization);
|
|
6704
7071
|
response.setHeader("Set-Cookie", [
|
|
6705
7072
|
cookie(SESSION_COOKIE, "", {
|
|
@@ -6741,6 +7108,21 @@ var MobileAccessGateway = class {
|
|
|
6741
7108
|
assertExternalTrust(request, policy, isMutation);
|
|
6742
7109
|
if (target.decodedPathname === "/api/mobile-access" || target.decodedPathname.startsWith(`/api/mobile-access/`)) throw new HttpError(404, "not_found");
|
|
6743
7110
|
if (request.method === "TRACE" || request.method === "CONNECT") throw new HttpError(405, "method_not_allowed");
|
|
7111
|
+
if (target.search === "" && (request.method === "GET" || request.method === "HEAD")) {
|
|
7112
|
+
const asset = await mobilePushAsset(target.decodedPathname);
|
|
7113
|
+
if (asset !== void 0) {
|
|
7114
|
+
const body = Buffer.from(asset.body);
|
|
7115
|
+
setSecurityHeaders(response, this.tlsEnabled);
|
|
7116
|
+
response.writeHead(200, {
|
|
7117
|
+
"Content-Type": asset.contentType,
|
|
7118
|
+
"Content-Length": body.byteLength,
|
|
7119
|
+
"Cache-Control": "public, max-age=3600"
|
|
7120
|
+
});
|
|
7121
|
+
if (request.method === "HEAD") response.end();
|
|
7122
|
+
else response.end(body);
|
|
7123
|
+
return;
|
|
7124
|
+
}
|
|
7125
|
+
}
|
|
6744
7126
|
if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/health`) {
|
|
6745
7127
|
sendJson(response, 200, { ok: true }, this.tlsEnabled);
|
|
6746
7128
|
return;
|
|
@@ -6832,8 +7214,17 @@ var MobileAccessGateway = class {
|
|
|
6832
7214
|
const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
|
|
6833
7215
|
const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
|
|
6834
7216
|
const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
|
|
7217
|
+
const mobilePushRoute = [
|
|
7218
|
+
MOBILE_PUSH_CONFIG_PATH,
|
|
7219
|
+
MOBILE_PUSH_SUBSCRIPTION_PATH,
|
|
7220
|
+
MOBILE_PUSH_PREFERENCES_PATH,
|
|
7221
|
+
MOBILE_PUSH_DISABLE_PATH,
|
|
7222
|
+
MOBILE_PUSH_TEST_PATH,
|
|
7223
|
+
MOBILE_PUSH_RESOLVE_PATH
|
|
7224
|
+
].includes(target.decodedPathname);
|
|
6835
7225
|
const requestedExtension = extensionTarget(target.decodedPathname);
|
|
6836
7226
|
const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
|
|
7227
|
+
const requestedCompatRevision = mobileCompatibilityRevision(target.decodedPathname);
|
|
6837
7228
|
const customAsset = request.method === "GET" || request.method === "HEAD" ? target.decodedPathname === `/mobile-access/custom.css` ? {
|
|
6838
7229
|
file: this.config.customCssFile,
|
|
6839
7230
|
contentType: "text/css; charset=utf-8",
|
|
@@ -6850,8 +7241,13 @@ var MobileAccessGateway = class {
|
|
|
6850
7241
|
file: this.config.mobileCompatibilityFile,
|
|
6851
7242
|
contentType: "text/javascript; charset=utf-8",
|
|
6852
7243
|
fallback: void 0
|
|
6853
|
-
} : void 0
|
|
6854
|
-
|
|
7244
|
+
} : requestedCompatRevision === void 0 ? void 0 : {
|
|
7245
|
+
file: this.config.mobileCompatibilityFile,
|
|
7246
|
+
contentType: "text/javascript; charset=utf-8",
|
|
7247
|
+
fallback: void 0,
|
|
7248
|
+
digest: requestedCompatRevision
|
|
7249
|
+
} : void 0;
|
|
7250
|
+
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
7251
|
if (![
|
|
6856
7252
|
"GET",
|
|
6857
7253
|
"HEAD",
|
|
@@ -6888,6 +7284,10 @@ var MobileAccessGateway = class {
|
|
|
6888
7284
|
this.authFailures.record("csrf", mapError(error).status, request, target.decodedPathname, policy.canonicalOrigin(request.headers.origin), authorization);
|
|
6889
7285
|
throw error;
|
|
6890
7286
|
}
|
|
7287
|
+
if (mobilePushRoute) {
|
|
7288
|
+
await this.handleMobilePushRequest(target, request, response, authorization);
|
|
7289
|
+
return;
|
|
7290
|
+
}
|
|
6891
7291
|
if (clientModules) {
|
|
6892
7292
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
6893
7293
|
if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
@@ -6953,23 +7353,32 @@ var MobileAccessGateway = class {
|
|
|
6953
7353
|
}
|
|
6954
7354
|
if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
|
|
6955
7355
|
operation.signal.throwIfAborted();
|
|
6956
|
-
const
|
|
7356
|
+
const compressible = isCompressibleContentType(customAsset.contentType) && body.byteLength >= MIN_COMPRESSIBLE_BYTES;
|
|
7357
|
+
const compressed = compressible && acceptsGzip(request.headers["accept-encoding"]);
|
|
7358
|
+
const digest = createHash("sha256").update(body).digest("hex");
|
|
7359
|
+
if (customAsset.digest !== void 0 && customAsset.digest !== digest) throw new HttpError(404, "not_found");
|
|
7360
|
+
const etag = compressed ? `"${digest}-gzip"` : `"${digest}"`;
|
|
6957
7361
|
const responseHeaders = { ETag: etag };
|
|
6958
7362
|
if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
|
|
7363
|
+
if (compressible) responseHeaders.Vary = "Accept-Encoding";
|
|
6959
7364
|
setSecurityHeaders(response, this.tlsEnabled);
|
|
6960
|
-
response.setHeader("Cache-Control", "private, no-cache");
|
|
7365
|
+
response.setHeader("Cache-Control", customAsset.digest === void 0 ? "private, no-cache" : "private, max-age=31536000, immutable");
|
|
6961
7366
|
if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
|
|
6962
7367
|
response.writeHead(304, responseHeaders);
|
|
6963
7368
|
response.end();
|
|
6964
7369
|
return;
|
|
6965
7370
|
}
|
|
7371
|
+
operation.signal.throwIfAborted();
|
|
7372
|
+
const payload = compressed ? await gzipBuffer(body) : body;
|
|
7373
|
+
operation.signal.throwIfAborted();
|
|
6966
7374
|
response.writeHead(200, {
|
|
6967
7375
|
...responseHeaders,
|
|
7376
|
+
...compressed ? { "Content-Encoding": "gzip" } : {},
|
|
6968
7377
|
"Content-Type": customAsset.contentType,
|
|
6969
|
-
"Content-Length":
|
|
7378
|
+
"Content-Length": payload.byteLength
|
|
6970
7379
|
});
|
|
6971
7380
|
if (request.method === "HEAD") response.end();
|
|
6972
|
-
else response.end(
|
|
7381
|
+
else response.end(payload);
|
|
6973
7382
|
return;
|
|
6974
7383
|
} finally {
|
|
6975
7384
|
operation.release();
|
|
@@ -7731,10 +8140,12 @@ var MobileAccessGateway = class {
|
|
|
7731
8140
|
const cookies = browserAuthCookies(request.headers.cookie);
|
|
7732
8141
|
const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
|
|
7733
8142
|
const viewId = randomBytes(16).toString("hex");
|
|
8143
|
+
const compatPath = await this.mobileCompatibilityUrl(operation.signal);
|
|
7734
8144
|
const rewritten = rewriteMobileIndexWithBatch(html, {
|
|
7735
8145
|
...options,
|
|
7736
8146
|
csrfCookie,
|
|
7737
|
-
viewId
|
|
8147
|
+
viewId,
|
|
8148
|
+
compatPath
|
|
7738
8149
|
});
|
|
7739
8150
|
operation.signal.throwIfAborted();
|
|
7740
8151
|
for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
|
|
@@ -7956,6 +8367,28 @@ var MobileAccessGateway = class {
|
|
|
7956
8367
|
await upstreamClosed;
|
|
7957
8368
|
}
|
|
7958
8369
|
}
|
|
8370
|
+
/**
|
|
8371
|
+
* Name the compatibility bundle by content, so a reload asks for it by revision.
|
|
8372
|
+
*
|
|
8373
|
+
* The digest is computed from the bytes on every page load rather than memoised on
|
|
8374
|
+
* a modification-time signature: an edit that keeps the length and restores the
|
|
8375
|
+
* timestamp (a build step preserving mtime, a same-size patch) is invisible to such
|
|
8376
|
+
* a signature, and the document would then name a revision the route refuses with
|
|
8377
|
+
* `404` — losing the shim exactly on the old WebViews it exists for. Hashing the
|
|
8378
|
+
* ~78 KB bundle costs far less than the page load it belongs to.
|
|
8379
|
+
*
|
|
8380
|
+
* A missing bundle keeps the plain path, where the route reports the usual `503`
|
|
8381
|
+
* instead of handing the document a URL that cannot exist.
|
|
8382
|
+
*/
|
|
8383
|
+
async mobileCompatibilityUrl(signal) {
|
|
8384
|
+
try {
|
|
8385
|
+
const body = await readFile(this.config.mobileCompatibilityFile, { signal });
|
|
8386
|
+
return mobileCompatibilityRevisionPath(createHash("sha256").update(body).digest("hex"));
|
|
8387
|
+
} catch (error) {
|
|
8388
|
+
if (signal.aborted) throw error;
|
|
8389
|
+
return MOBILE_COMPAT_PATH;
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
7959
8392
|
async serveMobileBootBatch(key, request, response, authorization) {
|
|
7960
8393
|
if (request.method !== "GET" && request.method !== "HEAD") throw new HttpError(405, "method_not_allowed");
|
|
7961
8394
|
const stored = this.mobileBootBatches.get(key);
|
|
@@ -8682,6 +9115,10 @@ var MobileAccessGateway = class {
|
|
|
8682
9115
|
}
|
|
8683
9116
|
async performClose() {
|
|
8684
9117
|
this.closing = true;
|
|
9118
|
+
const detachPushOrigin = this.detachPushOrigin;
|
|
9119
|
+
this.detachPushOrigin = void 0;
|
|
9120
|
+
detachPushOrigin?.();
|
|
9121
|
+
this.removePushDeviceListener();
|
|
8685
9122
|
const failures = [];
|
|
8686
9123
|
const directoryDrain = Promise.all(this.workspaceDirectoryTasks);
|
|
8687
9124
|
for (const controller of this.clientModuleOperations) controller.abort();
|
|
@@ -8733,6 +9170,7 @@ var MobileAccessGateway = class {
|
|
|
8733
9170
|
failures.push(error);
|
|
8734
9171
|
}
|
|
8735
9172
|
await accessClose;
|
|
9173
|
+
await Promise.allSettled(this.pushDeviceRevocations);
|
|
8736
9174
|
await Promise.allSettled(this.clientModuleTasks);
|
|
8737
9175
|
await Promise.allSettled(this.clientModuleRevocations);
|
|
8738
9176
|
await Promise.allSettled(this.mobileGraphTasks);
|
|
@@ -14589,6 +15027,22 @@ var IrohComponentManager = class {
|
|
|
14589
15027
|
if (failures.length > 0) throw new AggregateError(failures, "iroh_component_cleanup_failed");
|
|
14590
15028
|
}
|
|
14591
15029
|
}
|
|
15030
|
+
/** Remove the installed sidecar component from the plugin private directory. */
|
|
15031
|
+
async purge() {
|
|
15032
|
+
return this.enqueue(async () => {
|
|
15033
|
+
const failures = [];
|
|
15034
|
+
try {
|
|
15035
|
+
await removeIrohTree(this.stateDirectory, this.componentStorage);
|
|
15036
|
+
} catch (error) {
|
|
15037
|
+
if (error.code !== "ENOENT") failures.push(error);
|
|
15038
|
+
}
|
|
15039
|
+
this.cachedExecutable = void 0;
|
|
15040
|
+
this.origin = void 0;
|
|
15041
|
+
this.cached = this.baseStatus();
|
|
15042
|
+
if (failures.length > 0) throw new AggregateError(failures, "iroh_component_cleanup_failed");
|
|
15043
|
+
return this.cached;
|
|
15044
|
+
});
|
|
15045
|
+
}
|
|
14592
15046
|
async refresh() {
|
|
14593
15047
|
if (this.artifact === void 0) {
|
|
14594
15048
|
this.origin = void 0;
|
|
@@ -19297,9 +19751,37 @@ function androidRelease(version, tag) {
|
|
|
19297
19751
|
if (typeof version !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.test(version) || parseSemver(version) === void 0 || tag !== `v${version}` && tag !== `android-v${version}`) return void 0;
|
|
19298
19752
|
return Object.freeze({
|
|
19299
19753
|
version,
|
|
19754
|
+
tag,
|
|
19300
19755
|
downloadUrl: `https://github.com/saya-ch/dsh-mobile/releases/download/${tag}/dsh-mobile-android-v${version}.apk`
|
|
19301
19756
|
});
|
|
19302
19757
|
}
|
|
19758
|
+
/** Read the published APK byte sizes from the GitHub release assets (best effort). */
|
|
19759
|
+
async function fetchAndroidAssetSizes(fetcher, version, tag, signal) {
|
|
19760
|
+
try {
|
|
19761
|
+
const response = await fetcher(`${GITHUB_RELEASE_API}${encodeURIComponent(tag)}`, {
|
|
19762
|
+
headers: {
|
|
19763
|
+
accept: "application/vnd.github+json",
|
|
19764
|
+
"user-agent": "dsh-mobile-release-check"
|
|
19765
|
+
},
|
|
19766
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
19767
|
+
});
|
|
19768
|
+
if (!response.ok) return {};
|
|
19769
|
+
const payload = await response.json();
|
|
19770
|
+
if (payload === null || typeof payload !== "object" || !("assets" in payload) || !Array.isArray(payload.assets)) return {};
|
|
19771
|
+
const assets = payload.assets;
|
|
19772
|
+
const sizeOf = (name) => {
|
|
19773
|
+
for (const asset of assets) if (asset !== null && typeof asset === "object" && "name" in asset && asset.name === name && "size" in asset && typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size > 0) return asset.size;
|
|
19774
|
+
};
|
|
19775
|
+
const compatBytes = sizeOf(`dsh-mobile-android-v${version}.apk`);
|
|
19776
|
+
const irohBytes = sizeOf(`dsh-mobile-android-v${version}-iroh.apk`);
|
|
19777
|
+
return {
|
|
19778
|
+
...compatBytes === void 0 ? {} : { compatBytes },
|
|
19779
|
+
...irohBytes === void 0 ? {} : { irohBytes }
|
|
19780
|
+
};
|
|
19781
|
+
} catch {
|
|
19782
|
+
return {};
|
|
19783
|
+
}
|
|
19784
|
+
}
|
|
19303
19785
|
function androidReleaseDescriptor(payload) {
|
|
19304
19786
|
if (typeof payload !== "object" || payload === null) return void 0;
|
|
19305
19787
|
const fields = payload;
|
|
@@ -19518,6 +20000,9 @@ var PluginReleaseManager = class {
|
|
|
19518
20000
|
now;
|
|
19519
20001
|
cache;
|
|
19520
20002
|
activeUpdate;
|
|
20003
|
+
assetLookupOwner = new AbortController();
|
|
20004
|
+
assetLookup;
|
|
20005
|
+
assetSizes;
|
|
19521
20006
|
constructor(options) {
|
|
19522
20007
|
this.profileDirectory = options.profileDirectory;
|
|
19523
20008
|
this.installedVersion = options.installedVersion ?? DSH_MOBILE_VERSION;
|
|
@@ -19533,12 +20018,16 @@ var PluginReleaseManager = class {
|
|
|
19533
20018
|
const npmVersion = fetchNpmVersion(this.fetcher);
|
|
19534
20019
|
const [npmResult, androidResult, notesResult] = await Promise.allSettled([
|
|
19535
20020
|
npmVersion,
|
|
19536
|
-
fetchAndroidRelease(this.fetcher)
|
|
20021
|
+
fetchAndroidRelease(this.fetcher).then((android) => {
|
|
20022
|
+
if (android !== void 0) this.startAssetLookup(android);
|
|
20023
|
+
return android;
|
|
20024
|
+
}),
|
|
19537
20025
|
npmVersion.then((version) => version === void 0 ? void 0 : fetchReleaseNotes(this.fetcher, version))
|
|
19538
20026
|
]);
|
|
19539
20027
|
const latestVersion = npmResult.status === "fulfilled" ? npmResult.value : void 0;
|
|
19540
20028
|
const android = androidResult.status === "fulfilled" ? androidResult.value : void 0;
|
|
19541
20029
|
const releaseNotes = notesResult.status === "fulfilled" ? notesResult.value : void 0;
|
|
20030
|
+
const androidSizes = this.assetSizes?.downloadUrl === android?.downloadUrl ? this.assetSizes : void 0;
|
|
19542
20031
|
const comparison = latestVersion === void 0 ? void 0 : comparePluginVersions(latestVersion, this.installedVersion);
|
|
19543
20032
|
const status = Object.freeze({
|
|
19544
20033
|
installedVersion: this.installedVersion,
|
|
@@ -19547,6 +20036,8 @@ var PluginReleaseManager = class {
|
|
|
19547
20036
|
updateSupported,
|
|
19548
20037
|
...android === void 0 ? {} : { androidVersion: android.version },
|
|
19549
20038
|
androidDownloadUrl: android?.downloadUrl ?? GITHUB_RELEASES_URL,
|
|
20039
|
+
...androidSizes?.compatBytes === void 0 ? {} : { androidCompatBytes: androidSizes.compatBytes },
|
|
20040
|
+
...androidSizes?.irohBytes === void 0 ? {} : { androidIrohBytes: androidSizes.irohBytes },
|
|
19550
20041
|
...releaseNotes === void 0 ? {} : { releaseNotes }
|
|
19551
20042
|
});
|
|
19552
20043
|
this.cache = {
|
|
@@ -19555,6 +20046,31 @@ var PluginReleaseManager = class {
|
|
|
19555
20046
|
};
|
|
19556
20047
|
return status;
|
|
19557
20048
|
}
|
|
20049
|
+
/** Cancel and drain the optional APK-size lookup without delaying provider cleanup. */
|
|
20050
|
+
async dispose() {
|
|
20051
|
+
this.assetLookupOwner.abort();
|
|
20052
|
+
await this.assetLookup;
|
|
20053
|
+
}
|
|
20054
|
+
startAssetLookup(android) {
|
|
20055
|
+
if (this.assetLookupOwner.signal.aborted || this.assetLookup !== void 0 || this.assetSizes?.downloadUrl === android.downloadUrl) return;
|
|
20056
|
+
this.assetLookup = fetchAndroidAssetSizes(this.fetcher, android.version, android.tag, this.assetLookupOwner.signal).then((sizes) => {
|
|
20057
|
+
if (this.assetLookupOwner.signal.aborted) return;
|
|
20058
|
+
this.assetSizes = {
|
|
20059
|
+
downloadUrl: android.downloadUrl,
|
|
20060
|
+
...sizes
|
|
20061
|
+
};
|
|
20062
|
+
if (this.cache?.status.androidDownloadUrl === android.downloadUrl) this.cache = {
|
|
20063
|
+
...this.cache,
|
|
20064
|
+
status: Object.freeze({
|
|
20065
|
+
...this.cache.status,
|
|
20066
|
+
...sizes.compatBytes === void 0 ? {} : { androidCompatBytes: sizes.compatBytes },
|
|
20067
|
+
...sizes.irohBytes === void 0 ? {} : { androidIrohBytes: sizes.irohBytes }
|
|
20068
|
+
})
|
|
20069
|
+
};
|
|
20070
|
+
}).finally(() => {
|
|
20071
|
+
this.assetLookup = void 0;
|
|
20072
|
+
});
|
|
20073
|
+
}
|
|
19558
20074
|
/** Install the latest npm release into the active profile, then require a DSH restart. */
|
|
19559
20075
|
async update() {
|
|
19560
20076
|
if (this.activeUpdate !== void 0) return this.activeUpdate;
|
|
@@ -19693,77 +20209,2966 @@ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
|
|
|
19693
20209
|
return file;
|
|
19694
20210
|
}
|
|
19695
20211
|
//#endregion
|
|
19696
|
-
//#region src/
|
|
19697
|
-
const
|
|
19698
|
-
const
|
|
19699
|
-
|
|
19700
|
-
|
|
19701
|
-
|
|
19702
|
-
|
|
19703
|
-
|
|
19704
|
-
|
|
19705
|
-
|
|
19706
|
-
|
|
19707
|
-
|
|
19708
|
-
|
|
19709
|
-
|
|
19710
|
-
|
|
19711
|
-
|
|
19712
|
-
|
|
19713
|
-
|
|
19714
|
-
|
|
19715
|
-
|
|
19716
|
-
|
|
20212
|
+
//#region src/mobile-push/contracts.ts
|
|
20213
|
+
const OUTCOME_COALESCE_MS = 1e3;
|
|
20214
|
+
const ATTENTION_TTL_MS = 3e5;
|
|
20215
|
+
const OUTCOME_TTL_MS = 18e5;
|
|
20216
|
+
const ATTEMPT_TIMEOUT_MS = 1e4;
|
|
20217
|
+
//#endregion
|
|
20218
|
+
//#region src/mobile-push/requests.ts
|
|
20219
|
+
var RequestLifetimes = class {
|
|
20220
|
+
#records = /* @__PURE__ */ new Map();
|
|
20221
|
+
#byCall = /* @__PURE__ */ new Map();
|
|
20222
|
+
#clock;
|
|
20223
|
+
#emit;
|
|
20224
|
+
#ctx;
|
|
20225
|
+
#closed = false;
|
|
20226
|
+
constructor(ctx, emit, clock = Date.now) {
|
|
20227
|
+
this.#ctx = ctx;
|
|
20228
|
+
this.#emit = emit;
|
|
20229
|
+
this.#clock = clock;
|
|
20230
|
+
}
|
|
20231
|
+
observeQuestion(agent, request, next) {
|
|
20232
|
+
const question = Array.isArray(request?.questions) ? request.questions[0] : void 0;
|
|
20233
|
+
if (!question || !request?.signal?.aborted) {
|
|
20234
|
+
const intent = question?.intent;
|
|
20235
|
+
const category = intent?.kind === "plan-review" ? "plan" : "question";
|
|
20236
|
+
const callId = intent?.kind === "plan-review" && typeof intent.callId === "string" ? intent.callId : typeof request?.wait?.callId === "string" ? request.wait.callId : void 0;
|
|
20237
|
+
const sessionId = String(agent?.id ?? request?.agent?.id ?? "");
|
|
20238
|
+
const rec = sessionId ? this.#record(agent, sessionId, category, callId, request?.signal, ATTENTION_TTL_MS, request?.wait?.timed === true) : void 0;
|
|
20239
|
+
if (rec) this.#arm(rec);
|
|
20240
|
+
return Promise.resolve().then(next).then((result) => {
|
|
20241
|
+
if (rec && !(rec.timed && result && typeof result === "object" && result.pending === true)) {
|
|
20242
|
+
rec.settled = true;
|
|
20243
|
+
rec.live = false;
|
|
20244
|
+
}
|
|
20245
|
+
return result;
|
|
20246
|
+
}, (error) => {
|
|
20247
|
+
if (rec && !(rec.timed && ["NO_PROVIDER", "ASK_TIMED_OUT"].includes(error?.code))) {
|
|
20248
|
+
rec.settled = true;
|
|
20249
|
+
rec.live = false;
|
|
20250
|
+
}
|
|
20251
|
+
throw error;
|
|
20252
|
+
});
|
|
20253
|
+
}
|
|
20254
|
+
return Promise.resolve().then(next);
|
|
19717
20255
|
}
|
|
19718
|
-
|
|
19719
|
-
|
|
19720
|
-
|
|
19721
|
-
|
|
19722
|
-
|
|
19723
|
-
|
|
19724
|
-
|
|
19725
|
-
|
|
20256
|
+
observeApproval(agent, request, next) {
|
|
20257
|
+
const sessionId = String(agent?.id ?? request?.agent?.id ?? "");
|
|
20258
|
+
const rec = !request?.signal?.aborted && sessionId ? this.#record(agent, sessionId, "approval", request?.callId, request?.signal, ATTENTION_TTL_MS) : void 0;
|
|
20259
|
+
if (rec) this.#arm(rec);
|
|
20260
|
+
return Promise.resolve().then(next).finally(() => {
|
|
20261
|
+
if (rec) {
|
|
20262
|
+
rec.settled = true;
|
|
20263
|
+
rec.live = false;
|
|
20264
|
+
}
|
|
20265
|
+
});
|
|
20266
|
+
}
|
|
20267
|
+
queuedReply(sessionId, callId) {
|
|
20268
|
+
const rec = this.#byCall.get(`${sessionId}\u0000${callId}`);
|
|
20269
|
+
if (rec) this.#invalidate(rec, true);
|
|
20270
|
+
}
|
|
20271
|
+
invalidateSession(sessionId, options = {}) {
|
|
20272
|
+
for (const rec of this.#records.values()) if (rec.source.sessionId === sessionId && !(options.preserveTimed && rec.timed)) this.#invalidate(rec, false);
|
|
20273
|
+
}
|
|
20274
|
+
reconcileLive(source) {
|
|
20275
|
+
if (source.expiresAt <= this.#clock()) return "stale";
|
|
20276
|
+
const rec = this.#records.get(source.sourceId);
|
|
20277
|
+
if (!rec) return "unverifiable";
|
|
20278
|
+
if (this.#closed || rec.settled || rec.queuedReply || !rec.live) return "stale";
|
|
20279
|
+
const timed = this.#callForSource(source);
|
|
20280
|
+
if (rec.timed && !timed) return "unverifiable";
|
|
20281
|
+
if (!timed) return "eligible";
|
|
20282
|
+
const state = this.#projection(source.sessionId);
|
|
20283
|
+
const view = state?.questions ?? state;
|
|
20284
|
+
if ((Array.isArray(view?.active) ? view.active : []).some((item) => item.callId === timed && (item.state === "open" || item.state === "continued"))) return "eligible";
|
|
20285
|
+
if (rec.timed) return "stale";
|
|
20286
|
+
if ((Array.isArray(view?.settled) ? view.settled : []).some((item) => item.callId === timed)) return "stale";
|
|
20287
|
+
return "eligible";
|
|
20288
|
+
}
|
|
20289
|
+
eligible(source) {
|
|
20290
|
+
return this.reconcileLive(source) === "eligible";
|
|
20291
|
+
}
|
|
20292
|
+
/** Retires expired records at their original TTL; returns how many were removed. */
|
|
20293
|
+
sweep(now = this.#clock()) {
|
|
20294
|
+
let removed = 0;
|
|
20295
|
+
for (const [sourceId, rec] of this.#records) {
|
|
20296
|
+
if (rec.source.expiresAt > now) continue;
|
|
20297
|
+
this.#retire(rec);
|
|
20298
|
+
this.#records.delete(sourceId);
|
|
20299
|
+
removed += 1;
|
|
20300
|
+
}
|
|
20301
|
+
return removed;
|
|
20302
|
+
}
|
|
20303
|
+
close() {
|
|
20304
|
+
this.#closed = true;
|
|
20305
|
+
for (const rec of this.#records.values()) this.#retire(rec);
|
|
20306
|
+
this.#records.clear();
|
|
20307
|
+
this.#byCall.clear();
|
|
20308
|
+
}
|
|
20309
|
+
#projection(sessionId) {
|
|
20310
|
+
const session = this.#ctx.agents?.get?.(sessionId)?.session ?? { id: sessionId };
|
|
20311
|
+
return this.#ctx.sessionProjections?.stateOf?.(session, "userQuestions");
|
|
20312
|
+
}
|
|
20313
|
+
#retire(rec) {
|
|
20314
|
+
if (rec.timer) {
|
|
20315
|
+
clearTimeout(rec.timer);
|
|
20316
|
+
rec.timer = void 0;
|
|
20317
|
+
}
|
|
20318
|
+
rec.disposeAbort?.();
|
|
20319
|
+
rec.disposeAbort = void 0;
|
|
20320
|
+
if (typeof rec.source.callId === "string") {
|
|
20321
|
+
const key = `${rec.source.sessionId}\u0000${rec.source.callId}`;
|
|
20322
|
+
if (this.#byCall.get(key) === rec) this.#byCall.delete(key);
|
|
20323
|
+
}
|
|
20324
|
+
}
|
|
20325
|
+
#invalidate(rec, queuedReply) {
|
|
20326
|
+
rec.settled = true;
|
|
20327
|
+
rec.live = false;
|
|
20328
|
+
if (queuedReply) rec.queuedReply = true;
|
|
20329
|
+
if (rec.timer) clearTimeout(rec.timer);
|
|
20330
|
+
rec.timer = void 0;
|
|
20331
|
+
}
|
|
20332
|
+
#callForSource(source) {
|
|
20333
|
+
return source.callId;
|
|
20334
|
+
}
|
|
20335
|
+
#record(agent, sessionId, category, callId, signal, ttl, timed = false) {
|
|
20336
|
+
const createdAt = this.#clock();
|
|
20337
|
+
const sourceId = `request:${sessionId}:${category}:${typeof callId === "string" && callId ? callId : `dispatch-${crypto.randomUUID()}`}`;
|
|
20338
|
+
const existing = this.#records.get(sourceId);
|
|
20339
|
+
if (existing && existing.source.expiresAt > createdAt) return existing;
|
|
20340
|
+
if (existing) this.#retire(existing);
|
|
20341
|
+
let turn;
|
|
19726
20342
|
try {
|
|
19727
|
-
|
|
19728
|
-
|
|
19729
|
-
|
|
19730
|
-
|
|
19731
|
-
|
|
20343
|
+
const boundary = this.#ctx.sessionProjections?.stateOf?.(agent.session ?? agent, "turnBoundary");
|
|
20344
|
+
if (boundary?.openTurnStartSeq != null && Number.isSafeInteger(boundary?.lastTurn)) turn = boundary.lastTurn;
|
|
20345
|
+
} catch {}
|
|
20346
|
+
const rec = {
|
|
20347
|
+
source: {
|
|
20348
|
+
sourceId,
|
|
20349
|
+
sessionId,
|
|
20350
|
+
targetSessionId: this.#root(agent).id,
|
|
20351
|
+
category,
|
|
20352
|
+
createdAt,
|
|
20353
|
+
expiresAt: createdAt + ttl,
|
|
20354
|
+
lifetime: "live",
|
|
20355
|
+
...turn === void 0 ? {} : { turn },
|
|
20356
|
+
...typeof callId === "string" ? { callId } : {}
|
|
20357
|
+
},
|
|
20358
|
+
settled: false,
|
|
20359
|
+
queuedReply: false,
|
|
20360
|
+
timed,
|
|
20361
|
+
live: true
|
|
20362
|
+
};
|
|
20363
|
+
this.#records.set(sourceId, rec);
|
|
20364
|
+
if (typeof callId === "string") this.#byCall.set(`${sessionId}\u0000${callId}`, rec);
|
|
20365
|
+
const onAbort = () => {
|
|
20366
|
+
if (rec.timed && (signal?.reason)?.code === "ASK_TIMED_OUT") return;
|
|
20367
|
+
rec.settled = true;
|
|
20368
|
+
rec.live = false;
|
|
20369
|
+
};
|
|
20370
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
20371
|
+
rec.disposeAbort = () => {
|
|
19732
20372
|
try {
|
|
19733
|
-
|
|
19734
|
-
} catch
|
|
19735
|
-
|
|
19736
|
-
|
|
19737
|
-
|
|
20373
|
+
signal?.removeEventListener("abort", onAbort);
|
|
20374
|
+
} catch {}
|
|
20375
|
+
};
|
|
20376
|
+
return rec;
|
|
20377
|
+
}
|
|
20378
|
+
#root(agent) {
|
|
20379
|
+
const registry = this.#ctx.agents;
|
|
20380
|
+
if (!registry?.isOwnedBy || !registry.get || !registry.list) return agent;
|
|
20381
|
+
let current = agent;
|
|
20382
|
+
for (let count = 0; count < 64; count++) {
|
|
20383
|
+
const parent = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
|
|
20384
|
+
if (!parent) break;
|
|
20385
|
+
current = parent;
|
|
20386
|
+
}
|
|
20387
|
+
return current;
|
|
20388
|
+
}
|
|
20389
|
+
#arm(rec) {
|
|
20390
|
+
if (rec.timer) return;
|
|
20391
|
+
rec.timer = setTimeout(() => {
|
|
20392
|
+
rec.timer = void 0;
|
|
20393
|
+
if (this.#closed || rec.settled || rec.queuedReply) return;
|
|
20394
|
+
this.#emit(rec.source);
|
|
20395
|
+
}, 250);
|
|
20396
|
+
}
|
|
20397
|
+
};
|
|
20398
|
+
//#endregion
|
|
20399
|
+
//#region src/mobile-push/outcomes.ts
|
|
20400
|
+
const isCompletedGoalPhase = (phase) => phase === "complete";
|
|
20401
|
+
var OutcomeLifetimes = class {
|
|
20402
|
+
#ctx;
|
|
20403
|
+
#emit;
|
|
20404
|
+
#pending = /* @__PURE__ */ new Map();
|
|
20405
|
+
#goals = /* @__PURE__ */ new Map();
|
|
20406
|
+
#blockedTurns = /* @__PURE__ */ new Map();
|
|
20407
|
+
#sent = /* @__PURE__ */ new Map();
|
|
20408
|
+
#closed = false;
|
|
20409
|
+
constructor(ctx, emit) {
|
|
20410
|
+
this.#ctx = ctx;
|
|
20411
|
+
this.#emit = emit;
|
|
20412
|
+
}
|
|
20413
|
+
onGoal(payload) {
|
|
20414
|
+
const agent = payload?.agent;
|
|
20415
|
+
const change = payload?.change;
|
|
20416
|
+
const goal = change?.goal;
|
|
20417
|
+
const op = change?.operation;
|
|
20418
|
+
if (!agent?.id || !change) return;
|
|
20419
|
+
this.#cancel(agent.id);
|
|
20420
|
+
if (op === "clear" || !goal) {
|
|
20421
|
+
this.#goals.delete(agent.id);
|
|
20422
|
+
return;
|
|
19738
20423
|
}
|
|
19739
|
-
|
|
19740
|
-
|
|
19741
|
-
|
|
19742
|
-
|
|
19743
|
-
|
|
19744
|
-
|
|
20424
|
+
const id = String(goal.id ?? change?.ref?.id ?? "");
|
|
20425
|
+
const revision = Number(goal.revision ?? change?.ref?.revision);
|
|
20426
|
+
if (!id || !Number.isFinite(revision)) return;
|
|
20427
|
+
const record = {
|
|
20428
|
+
id,
|
|
20429
|
+
revision,
|
|
20430
|
+
phase: String(goal.phase ?? op ?? "")
|
|
20431
|
+
};
|
|
20432
|
+
this.#goals.set(agent.id, record);
|
|
20433
|
+
if (op === "block") {
|
|
20434
|
+
const boundary = this.#ctx.sessionProjections?.stateOf?.(agent.session ?? agent, "turnBoundary");
|
|
20435
|
+
const turn = boundary?.openTurnStartSeq != null && Number.isSafeInteger(boundary?.lastTurn) ? boundary.lastTurn : void 0;
|
|
20436
|
+
if (turn !== void 0) this.#blockedTurns.set(agent.id, {
|
|
20437
|
+
id,
|
|
20438
|
+
revision,
|
|
20439
|
+
turn
|
|
20440
|
+
});
|
|
20441
|
+
this.#schedule(agent, "goal", turn, {
|
|
20442
|
+
id,
|
|
20443
|
+
revision
|
|
20444
|
+
});
|
|
19745
20445
|
}
|
|
19746
|
-
|
|
19747
|
-
|
|
19748
|
-
|
|
19749
|
-
|
|
19750
|
-
|
|
19751
|
-
|
|
19752
|
-
|
|
19753
|
-
|
|
19754
|
-
|
|
19755
|
-
if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
|
|
19756
|
-
throw cleanupError;
|
|
20446
|
+
}
|
|
20447
|
+
onSessionEvent(session, event) {
|
|
20448
|
+
const type = event?.type;
|
|
20449
|
+
const data = event?.data ?? event;
|
|
20450
|
+
if (!session?.id || !data) return;
|
|
20451
|
+
const id = session.id;
|
|
20452
|
+
if (type === "agent/inbox/inserted" || type === "agent/inbox/spliced" || type === "turn/start") {
|
|
20453
|
+
this.#cancel(id);
|
|
20454
|
+
return;
|
|
19757
20455
|
}
|
|
20456
|
+
if (type !== "turn/end") return;
|
|
20457
|
+
const reason = data.reason?.kind;
|
|
20458
|
+
const closesBlockedTurn = this.#blockedTurns.get(id)?.turn === data.turn;
|
|
20459
|
+
if (closesBlockedTurn) this.#blockedTurns.delete(id);
|
|
20460
|
+
if (closesBlockedTurn && [
|
|
20461
|
+
"completed",
|
|
20462
|
+
"blocked",
|
|
20463
|
+
"error"
|
|
20464
|
+
].includes(reason)) return;
|
|
20465
|
+
if (reason === "completed") {
|
|
20466
|
+
const observedGoal = this.#goals.get(id);
|
|
20467
|
+
const activeGoal = observedGoal?.phase === "active" ? observedGoal : this.#projectedActiveGoal(session);
|
|
20468
|
+
if (observedGoal && isCompletedGoalPhase(observedGoal.phase)) {
|
|
20469
|
+
this.#schedule(session, "goal", data.turn, {
|
|
20470
|
+
id: observedGoal.id,
|
|
20471
|
+
revision: observedGoal.revision
|
|
20472
|
+
});
|
|
20473
|
+
this.#goals.delete(id);
|
|
20474
|
+
} else if (!activeGoal) this.#schedule(session, "response", data.turn);
|
|
20475
|
+
} else if (reason === "blocked" || reason === "error") this.#schedule(session, "failure", data.turn);
|
|
19758
20476
|
}
|
|
19759
|
-
|
|
20477
|
+
#projectedActiveGoal(session) {
|
|
20478
|
+
const goal = (this.#ctx.sessionProjections?.stateOf?.(session.session ?? session, "goal"))?.current?.goal;
|
|
20479
|
+
if (!goal || typeof goal.id !== "string" || !Number.isSafeInteger(goal.revision) || goal.phase !== "active") return void 0;
|
|
20480
|
+
return {
|
|
20481
|
+
id: goal.id,
|
|
20482
|
+
revision: goal.revision,
|
|
20483
|
+
phase: "active"
|
|
20484
|
+
};
|
|
20485
|
+
}
|
|
20486
|
+
onAgentError(_payload) {}
|
|
20487
|
+
reconcileLive(source) {
|
|
20488
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20489
|
+
if (this.#closed) return "stale";
|
|
20490
|
+
const candidate = this.#pending.get(source.sourceId);
|
|
20491
|
+
if (!candidate) return "unverifiable";
|
|
20492
|
+
return candidate.active ? "eligible" : "stale";
|
|
20493
|
+
}
|
|
20494
|
+
eligible(source) {
|
|
20495
|
+
return this.reconcileLive(source) === "eligible";
|
|
20496
|
+
}
|
|
20497
|
+
/** Retires expired candidates and sent-dedupe records at their original TTL; returns how many were removed. */
|
|
20498
|
+
sweep(now = Date.now()) {
|
|
20499
|
+
let removed = 0;
|
|
20500
|
+
for (const [id, candidate] of this.#pending) {
|
|
20501
|
+
if (candidate.source.expiresAt > now) continue;
|
|
20502
|
+
if (candidate.timer) {
|
|
20503
|
+
clearTimeout(candidate.timer);
|
|
20504
|
+
candidate.timer = void 0;
|
|
20505
|
+
}
|
|
20506
|
+
this.#pending.delete(id);
|
|
20507
|
+
removed += 1;
|
|
20508
|
+
}
|
|
20509
|
+
for (const [id, expiresAt] of this.#sent) {
|
|
20510
|
+
if (expiresAt > now) continue;
|
|
20511
|
+
this.#sent.delete(id);
|
|
20512
|
+
removed += 1;
|
|
20513
|
+
}
|
|
20514
|
+
return removed;
|
|
20515
|
+
}
|
|
20516
|
+
close() {
|
|
20517
|
+
this.#closed = true;
|
|
20518
|
+
for (const item of this.#pending.values()) if (item.timer) clearTimeout(item.timer);
|
|
20519
|
+
this.#pending.clear();
|
|
20520
|
+
this.#blockedTurns.clear();
|
|
20521
|
+
this.#goals.clear();
|
|
20522
|
+
this.#sent.clear();
|
|
20523
|
+
}
|
|
20524
|
+
#schedule(agent, category, turn, goal) {
|
|
20525
|
+
if (this.#closed || this.#isRuntimeChild(agent)) return;
|
|
20526
|
+
const createdAt = Date.now();
|
|
20527
|
+
const goalKey = goal ? `${goal.id}:${goal.revision}` : "";
|
|
20528
|
+
const sourceId = `${category}:${agent.id}:${turn ?? "goal"}:${goalKey}`;
|
|
20529
|
+
if (this.#sent.has(sourceId) || this.#pending.has(sourceId)) return;
|
|
20530
|
+
const source = {
|
|
20531
|
+
sourceId,
|
|
20532
|
+
sessionId: agent.id,
|
|
20533
|
+
targetSessionId: this.#root(agent).id,
|
|
20534
|
+
category,
|
|
20535
|
+
createdAt,
|
|
20536
|
+
expiresAt: createdAt + OUTCOME_TTL_MS,
|
|
20537
|
+
lifetime: "committed",
|
|
20538
|
+
...turn === void 0 ? {} : { turn },
|
|
20539
|
+
...goal === void 0 ? {} : { goal }
|
|
20540
|
+
};
|
|
20541
|
+
const candidate = {
|
|
20542
|
+
source,
|
|
20543
|
+
active: true
|
|
20544
|
+
};
|
|
20545
|
+
candidate.timer = setTimeout(() => {
|
|
20546
|
+
candidate.timer = void 0;
|
|
20547
|
+
if (this.#closed || !candidate.active) return;
|
|
20548
|
+
this.#sent.set(sourceId, source.expiresAt);
|
|
20549
|
+
this.#emit(source);
|
|
20550
|
+
}, OUTCOME_COALESCE_MS);
|
|
20551
|
+
this.#pending.set(sourceId, candidate);
|
|
20552
|
+
}
|
|
20553
|
+
#cancel(sessionId) {
|
|
20554
|
+
this.#blockedTurns.delete(sessionId);
|
|
20555
|
+
for (const [id, candidate] of this.#pending) {
|
|
20556
|
+
if (candidate.source.sessionId !== sessionId) continue;
|
|
20557
|
+
clearTimeout(candidate.timer);
|
|
20558
|
+
candidate.active = false;
|
|
20559
|
+
this.#pending.delete(id);
|
|
20560
|
+
}
|
|
20561
|
+
}
|
|
20562
|
+
#isRuntimeChild(agent) {
|
|
20563
|
+
const registry = this.#ctx.agents;
|
|
20564
|
+
if (!registry?.isOwnedBy || !registry.list) return false;
|
|
20565
|
+
return registry.list().some((owner) => registry.isOwnedBy?.(agent.id, owner));
|
|
20566
|
+
}
|
|
20567
|
+
#root(agent) {
|
|
20568
|
+
const registry = this.#ctx.agents;
|
|
20569
|
+
if (!registry?.isOwnedBy || !registry.list) return agent;
|
|
20570
|
+
let current = agent;
|
|
20571
|
+
for (let count = 0; count < 64; count++) {
|
|
20572
|
+
const owner = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
|
|
20573
|
+
if (!owner) return current;
|
|
20574
|
+
current = owner;
|
|
20575
|
+
}
|
|
20576
|
+
return current;
|
|
20577
|
+
}
|
|
20578
|
+
};
|
|
19760
20579
|
//#endregion
|
|
19761
|
-
//#region src/
|
|
19762
|
-
|
|
19763
|
-
|
|
19764
|
-
|
|
19765
|
-
|
|
19766
|
-
|
|
20580
|
+
//#region src/mobile-push/reconcile.ts
|
|
20581
|
+
const TURN_REASONS = [
|
|
20582
|
+
"completed",
|
|
20583
|
+
"error",
|
|
20584
|
+
"blocked",
|
|
20585
|
+
"aborted",
|
|
20586
|
+
"max-tokens",
|
|
20587
|
+
"interrupted",
|
|
20588
|
+
"forked",
|
|
20589
|
+
"other"
|
|
20590
|
+
];
|
|
20591
|
+
const GOAL_OPERATIONS = [
|
|
20592
|
+
"create",
|
|
20593
|
+
"edit",
|
|
20594
|
+
"pause",
|
|
20595
|
+
"resume",
|
|
20596
|
+
"complete",
|
|
20597
|
+
"block",
|
|
20598
|
+
"clear"
|
|
20599
|
+
];
|
|
20600
|
+
const GOAL_PHASES = [
|
|
20601
|
+
"active",
|
|
20602
|
+
"paused",
|
|
20603
|
+
"blocked",
|
|
20604
|
+
"complete",
|
|
20605
|
+
"cleared"
|
|
20606
|
+
];
|
|
20607
|
+
const turnEndSchema = z$1.object({
|
|
20608
|
+
turn: z$1.number().int().nonnegative(),
|
|
20609
|
+
reason: z$1.enum(TURN_REASONS)
|
|
20610
|
+
}).strict();
|
|
20611
|
+
const goalChangeSchema = z$1.object({
|
|
20612
|
+
id: z$1.string().min(1),
|
|
20613
|
+
revision: z$1.number().int().nonnegative(),
|
|
20614
|
+
phase: z$1.enum(GOAL_PHASES),
|
|
20615
|
+
operation: z$1.enum(GOAL_OPERATIONS),
|
|
20616
|
+
turn: z$1.number().int().nonnegative(),
|
|
20617
|
+
openTurn: z$1.boolean()
|
|
20618
|
+
}).strict();
|
|
20619
|
+
const mobilePushLifecycleSchema = z$1.object({
|
|
20620
|
+
latestTurn: z$1.number().int().nonnegative(),
|
|
20621
|
+
openTurn: z$1.number().int().nonnegative().nullable(),
|
|
20622
|
+
lastTurnEnd: turnEndSchema.nullable(),
|
|
20623
|
+
latestGoalChange: goalChangeSchema.nullable()
|
|
20624
|
+
}).strict();
|
|
20625
|
+
const MOBILE_PUSH_LIFECYCLE_KEY = "mobilePushLifecycle";
|
|
20626
|
+
const INITIAL_STATE = {
|
|
20627
|
+
latestTurn: 0,
|
|
20628
|
+
openTurn: null,
|
|
20629
|
+
lastTurnEnd: null,
|
|
20630
|
+
latestGoalChange: null
|
|
20631
|
+
};
|
|
20632
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20633
|
+
const safeNumber = (value) => Number.isSafeInteger(value) && value >= 0 ? value : void 0;
|
|
20634
|
+
/** Wire-visible verification metadata only; it stores no message text or diagnostics. */
|
|
20635
|
+
const mobilePushLifecycleProjectionDefinition = {
|
|
20636
|
+
key: MOBILE_PUSH_LIFECYCLE_KEY,
|
|
20637
|
+
stateSchema: mobilePushLifecycleSchema,
|
|
20638
|
+
init: (_header, _inheritedEventCount) => INITIAL_STATE,
|
|
20639
|
+
apply(state, event) {
|
|
20640
|
+
if (!isRecord(event)) return state;
|
|
20641
|
+
const data = isRecord(event.data) ? event.data : void 0;
|
|
20642
|
+
if (!data) return state;
|
|
20643
|
+
if (event.type === "turn/start") {
|
|
20644
|
+
const turn = safeNumber(data.turn);
|
|
20645
|
+
if (turn === void 0) return state;
|
|
20646
|
+
return {
|
|
20647
|
+
...state,
|
|
20648
|
+
latestTurn: turn,
|
|
20649
|
+
openTurn: turn,
|
|
20650
|
+
lastTurnEnd: null
|
|
20651
|
+
};
|
|
20652
|
+
}
|
|
20653
|
+
if (event.type === "turn/end") {
|
|
20654
|
+
const turn = safeNumber(data.turn);
|
|
20655
|
+
const rawReason = (isRecord(data.reason) ? data.reason : void 0)?.kind;
|
|
20656
|
+
if (turn === void 0 || typeof rawReason !== "string") return state;
|
|
20657
|
+
const reason = TURN_REASONS.includes(rawReason) ? rawReason : "other";
|
|
20658
|
+
return {
|
|
20659
|
+
...state,
|
|
20660
|
+
latestTurn: turn,
|
|
20661
|
+
openTurn: null,
|
|
20662
|
+
lastTurnEnd: {
|
|
20663
|
+
turn,
|
|
20664
|
+
reason
|
|
20665
|
+
}
|
|
20666
|
+
};
|
|
20667
|
+
}
|
|
20668
|
+
if (event.type !== "goal/change") return state;
|
|
20669
|
+
const rawOperation = data.operation;
|
|
20670
|
+
if (typeof rawOperation !== "string" || !GOAL_OPERATIONS.includes(rawOperation)) return state;
|
|
20671
|
+
const operation = rawOperation;
|
|
20672
|
+
const goal = isRecord(data.goal) ? data.goal : void 0;
|
|
20673
|
+
const cleared = isRecord(data.cleared) ? data.cleared : void 0;
|
|
20674
|
+
const goalId = goal?.id;
|
|
20675
|
+
const clearedId = cleared?.id;
|
|
20676
|
+
const id = typeof goalId === "string" ? goalId : typeof clearedId === "string" ? clearedId : void 0;
|
|
20677
|
+
const revision = safeNumber(goal?.revision ?? cleared?.revision);
|
|
20678
|
+
const phaseValue = operation === "clear" ? "cleared" : goal?.phase;
|
|
20679
|
+
if (!id || revision === void 0 || typeof phaseValue !== "string" || !GOAL_PHASES.includes(phaseValue)) return state;
|
|
20680
|
+
const phase = phaseValue;
|
|
20681
|
+
return {
|
|
20682
|
+
...state,
|
|
20683
|
+
latestGoalChange: {
|
|
20684
|
+
id,
|
|
20685
|
+
revision,
|
|
20686
|
+
phase,
|
|
20687
|
+
operation,
|
|
20688
|
+
turn: state.latestTurn,
|
|
20689
|
+
openTurn: state.openTurn !== null
|
|
20690
|
+
}
|
|
20691
|
+
};
|
|
20692
|
+
},
|
|
20693
|
+
wire: {
|
|
20694
|
+
viewSchema: mobilePushLifecycleSchema,
|
|
20695
|
+
view: (state) => state
|
|
20696
|
+
},
|
|
20697
|
+
stateVersion: 1
|
|
20698
|
+
};
|
|
20699
|
+
function projectionValues(ctx, agent) {
|
|
20700
|
+
try {
|
|
20701
|
+
return ctx.sessionProjections?.snapshot?.(agent.session ?? agent)?.values;
|
|
20702
|
+
} catch {
|
|
20703
|
+
return;
|
|
20704
|
+
}
|
|
20705
|
+
}
|
|
20706
|
+
function queueArrays(values, agent) {
|
|
20707
|
+
const queues = [];
|
|
20708
|
+
const inbox = isRecord(values.inbox) ? values.inbox : void 0;
|
|
20709
|
+
const projected = [inbox?.["next-turn"], inbox?.["next-step"]];
|
|
20710
|
+
if (projected.every(Array.isArray)) queues.push(...projected);
|
|
20711
|
+
const live = [agent?.inbox?.nextTurn, agent?.inbox?.nextStep];
|
|
20712
|
+
if (live.every(Array.isArray)) queues.push(...live);
|
|
20713
|
+
return queues.length > 0 ? queues : void 0;
|
|
20714
|
+
}
|
|
20715
|
+
function inboxContains(values, predicate, agent) {
|
|
20716
|
+
const queues = queueArrays(values, agent);
|
|
20717
|
+
if (!queues) return void 0;
|
|
20718
|
+
return queues.some((queue) => queue.some(predicate));
|
|
20719
|
+
}
|
|
20720
|
+
function lifecycle(values) {
|
|
20721
|
+
const parsed = mobilePushLifecycleSchema.safeParse(values[MOBILE_PUSH_LIFECYCLE_KEY]);
|
|
20722
|
+
return parsed.success ? parsed.data : void 0;
|
|
20723
|
+
}
|
|
20724
|
+
function verifyQuestion(source, values, agent) {
|
|
20725
|
+
const callId = source.callId;
|
|
20726
|
+
if (typeof callId !== "string" || !callId) return "unverifiable";
|
|
20727
|
+
const replyQueued = inboxContains(values, (message) => {
|
|
20728
|
+
const source = isRecord(message) && isRecord(message.source) ? message.source : void 0;
|
|
20729
|
+
return source?.kind === "user-question-reply" && source.callId === callId && source.outcome === "answered";
|
|
20730
|
+
}, agent);
|
|
20731
|
+
if (replyQueued === void 0) return "unverifiable";
|
|
20732
|
+
if (replyQueued) return "stale";
|
|
20733
|
+
const view = isRecord(values.userQuestions) ? values.userQuestions : void 0;
|
|
20734
|
+
const activeItems = Array.isArray(view?.active) ? view.active : void 0;
|
|
20735
|
+
const settledItems = Array.isArray(view?.settled) ? view.settled : void 0;
|
|
20736
|
+
if (!activeItems || !settledItems) return "unverifiable";
|
|
20737
|
+
const active = activeItems.find((item) => isRecord(item) && item.callId === callId);
|
|
20738
|
+
if (active) {
|
|
20739
|
+
if (active.state !== "open" && active.state !== "continued") return "stale";
|
|
20740
|
+
const state = lifecycle(values);
|
|
20741
|
+
const turn = safeNumber(source.turn);
|
|
20742
|
+
if (turn === void 0 || !state) return "unverifiable";
|
|
20743
|
+
if (state.latestTurn < turn) return "unverifiable";
|
|
20744
|
+
return "eligible";
|
|
20745
|
+
}
|
|
20746
|
+
return settledItems.some((item) => isRecord(item) && item.callId === callId) ? "stale" : "unverifiable";
|
|
20747
|
+
}
|
|
20748
|
+
function verifyOutcome(source, values, agent) {
|
|
20749
|
+
const turn = safeNumber(source.turn);
|
|
20750
|
+
if (turn === void 0) return "unverifiable";
|
|
20751
|
+
const queuedInput = inboxContains(values, (message) => message !== void 0 && message !== null, agent);
|
|
20752
|
+
if (queuedInput === void 0) return "unverifiable";
|
|
20753
|
+
if (queuedInput) return "stale";
|
|
20754
|
+
const state = lifecycle(values);
|
|
20755
|
+
if (!state) return "unverifiable";
|
|
20756
|
+
if (state.latestTurn > turn) return "stale";
|
|
20757
|
+
if (state.latestTurn < turn) return "unverifiable";
|
|
20758
|
+
const ended = state.lastTurnEnd;
|
|
20759
|
+
if (!ended || ended.turn !== turn) return "unverifiable";
|
|
20760
|
+
const cause = state.latestGoalChange;
|
|
20761
|
+
const goalEnvelope = isRecord(values.goal) ? values.goal : void 0;
|
|
20762
|
+
const currentGoal = isRecord(goalEnvelope?.goal) ? goalEnvelope.goal : void 0;
|
|
20763
|
+
if (source.category === "response") {
|
|
20764
|
+
if (ended.reason !== "completed") return "stale";
|
|
20765
|
+
if (cause?.turn === turn && (cause.operation === "complete" || cause.operation === "block")) return "stale";
|
|
20766
|
+
if (currentGoal?.phase === "active" && cause?.turn === turn) return "stale";
|
|
20767
|
+
return "eligible";
|
|
20768
|
+
}
|
|
20769
|
+
if (source.category === "failure") {
|
|
20770
|
+
if (ended.reason !== "error" && ended.reason !== "blocked") return "stale";
|
|
20771
|
+
if (cause?.turn === turn && cause.operation === "block") return "stale";
|
|
20772
|
+
return "eligible";
|
|
20773
|
+
}
|
|
20774
|
+
return "unverifiable";
|
|
20775
|
+
}
|
|
20776
|
+
function verifyGoal(source, values) {
|
|
20777
|
+
const identity = source.goal;
|
|
20778
|
+
if (!identity) return "unverifiable";
|
|
20779
|
+
const goalEnvelope = isRecord(values.goal) ? values.goal : void 0;
|
|
20780
|
+
const goalView = isRecord(goalEnvelope?.goal) ? goalEnvelope.goal : void 0;
|
|
20781
|
+
if (!goalView) return values.goal === null ? "stale" : "unverifiable";
|
|
20782
|
+
if (goalView.id !== identity.id || goalView.revision !== identity.revision) return "stale";
|
|
20783
|
+
const phase = goalView.phase;
|
|
20784
|
+
if (phase !== "complete" && phase !== "blocked") return "stale";
|
|
20785
|
+
const state = lifecycle(values);
|
|
20786
|
+
if (!state) return "unverifiable";
|
|
20787
|
+
const cause = state.latestGoalChange;
|
|
20788
|
+
if (!cause) return "unverifiable";
|
|
20789
|
+
if (cause.id !== identity.id || cause.revision !== identity.revision || cause.phase !== phase) return "stale";
|
|
20790
|
+
const sourceTurn = safeNumber(source.turn);
|
|
20791
|
+
if (sourceTurn === void 0) {
|
|
20792
|
+
if (cause.operation !== "block") return "unverifiable";
|
|
20793
|
+
if (state.latestTurn !== cause.turn || state.openTurn !== null) return "stale";
|
|
20794
|
+
return "eligible";
|
|
20795
|
+
}
|
|
20796
|
+
if (state.latestTurn > sourceTurn) return "stale";
|
|
20797
|
+
if (state.latestTurn < sourceTurn || cause.turn > sourceTurn) return "unverifiable";
|
|
20798
|
+
if (phase === "blocked") {
|
|
20799
|
+
if (cause.operation !== "block" || cause.turn !== sourceTurn) return "stale";
|
|
20800
|
+
if (state.openTurn === sourceTurn) return "eligible";
|
|
20801
|
+
const ended = state.lastTurnEnd;
|
|
20802
|
+
return ended?.turn === sourceTurn && (ended.reason === "completed" || ended.reason === "blocked" || ended.reason === "error") ? "eligible" : "unverifiable";
|
|
20803
|
+
}
|
|
20804
|
+
if (cause.operation !== "complete") return "stale";
|
|
20805
|
+
const ended = state.lastTurnEnd;
|
|
20806
|
+
return ended?.turn === sourceTurn && ended.reason === "completed" ? "eligible" : "unverifiable";
|
|
20807
|
+
}
|
|
20808
|
+
var PushReconciler = class {
|
|
20809
|
+
#ctx;
|
|
20810
|
+
constructor(ctx) {
|
|
20811
|
+
this.#ctx = ctx;
|
|
20812
|
+
}
|
|
20813
|
+
registerProjection() {
|
|
20814
|
+
const dispose = this.#ctx.sessionProjections?.register?.(mobilePushLifecycleProjectionDefinition);
|
|
20815
|
+
if (typeof dispose !== "function") throw new Error("public session projection registration unavailable");
|
|
20816
|
+
return dispose;
|
|
20817
|
+
}
|
|
20818
|
+
async reconcile(source, liveProof) {
|
|
20819
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20820
|
+
if (liveProof !== "unverifiable") return liveProof;
|
|
20821
|
+
if (source.category === "plan" || source.category === "approval") return "unverifiable";
|
|
20822
|
+
if (source.category === "question" && !source.callId) return "unverifiable";
|
|
20823
|
+
const agent = this.#ctx.agents?.get?.(source.sessionId);
|
|
20824
|
+
if (agent) {
|
|
20825
|
+
const values = projectionValues(this.#ctx, agent);
|
|
20826
|
+
return values ? this.#verify(source, values, agent) : "unverifiable";
|
|
20827
|
+
}
|
|
20828
|
+
let observation;
|
|
20829
|
+
let result = "unverifiable";
|
|
20830
|
+
try {
|
|
20831
|
+
observation = await this.#ctx.sessionQuery?.observeSession?.(source.sessionId, { projectionMode: "all" });
|
|
20832
|
+
const values = observation?.projections?.values;
|
|
20833
|
+
if (values) result = this.#verify(source, values, void 0);
|
|
20834
|
+
} catch (error) {
|
|
20835
|
+
result = isRecord(error) && error.code === "SESSION_QUERY_SESSION_NOT_FOUND" ? "stale" : "unverifiable";
|
|
20836
|
+
} finally {
|
|
20837
|
+
try {
|
|
20838
|
+
observation?.[Symbol.dispose]();
|
|
20839
|
+
} catch {
|
|
20840
|
+
result = "unverifiable";
|
|
20841
|
+
}
|
|
20842
|
+
}
|
|
20843
|
+
return source.expiresAt <= Date.now() ? "stale" : result;
|
|
20844
|
+
}
|
|
20845
|
+
#verify(source, values, agent) {
|
|
20846
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20847
|
+
if (source.category === "question") return verifyQuestion(source, values, agent);
|
|
20848
|
+
if (source.category === "goal") return verifyGoal(source, values);
|
|
20849
|
+
return verifyOutcome(source, values, agent);
|
|
20850
|
+
}
|
|
20851
|
+
};
|
|
20852
|
+
//#endregion
|
|
20853
|
+
//#region src/mobile-push/host.ts
|
|
20854
|
+
function hasContract(ctx) {
|
|
20855
|
+
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;
|
|
20856
|
+
}
|
|
20857
|
+
const MAINTENANCE_INTERVAL_MS = 3e4;
|
|
20858
|
+
function createPushHost(ctx) {
|
|
20859
|
+
let available = hasContract(ctx);
|
|
20860
|
+
const reconciler = new PushReconciler(ctx);
|
|
20861
|
+
let lifecycleProjectionDisposer;
|
|
20862
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
20863
|
+
const changeSubscribers = /* @__PURE__ */ new Set();
|
|
20864
|
+
const disposers = [];
|
|
20865
|
+
let questionDisposer;
|
|
20866
|
+
let approvalDisposer;
|
|
20867
|
+
const add = (source) => {
|
|
20868
|
+
if (closed || !available) return;
|
|
20869
|
+
for (const callback of subscribers) try {
|
|
20870
|
+
callback(source);
|
|
20871
|
+
} catch {}
|
|
20872
|
+
};
|
|
20873
|
+
const requests = new RequestLifetimes(ctx, add);
|
|
20874
|
+
const outcomes = new OutcomeLifetimes(ctx, add);
|
|
20875
|
+
let closed = false;
|
|
20876
|
+
const maintenanceTimer = available ? setInterval(() => {
|
|
20877
|
+
try {
|
|
20878
|
+
requests.sweep();
|
|
20879
|
+
outcomes.sweep();
|
|
20880
|
+
} catch {}
|
|
20881
|
+
}, MAINTENANCE_INTERVAL_MS) : void 0;
|
|
20882
|
+
if (available) try {
|
|
20883
|
+
lifecycleProjectionDisposer = reconciler.registerProjection();
|
|
20884
|
+
} catch {
|
|
20885
|
+
available = false;
|
|
20886
|
+
}
|
|
20887
|
+
const disposeLifecycleProjection = () => {
|
|
20888
|
+
const dispose = lifecycleProjectionDisposer;
|
|
20889
|
+
lifecycleProjectionDisposer = void 0;
|
|
20890
|
+
try {
|
|
20891
|
+
dispose?.();
|
|
20892
|
+
} catch {}
|
|
20893
|
+
};
|
|
20894
|
+
const register = (event, listener, prepend = false) => {
|
|
20895
|
+
try {
|
|
20896
|
+
const dispose = ctx.on(event, listener, {
|
|
20897
|
+
global: true,
|
|
20898
|
+
...prepend ? { prepend: true } : {}
|
|
20899
|
+
});
|
|
20900
|
+
return typeof dispose === "function" ? dispose : void 0;
|
|
20901
|
+
} catch {
|
|
20902
|
+
return;
|
|
20903
|
+
}
|
|
20904
|
+
};
|
|
20905
|
+
const questionObserver = function(request, next) {
|
|
20906
|
+
return requests.observeQuestion(request?.agent ?? this, request, next);
|
|
20907
|
+
};
|
|
20908
|
+
const approvalObserver = function(request, next) {
|
|
20909
|
+
return requests.observeApproval(request?.agent ?? this, request, next);
|
|
20910
|
+
};
|
|
20911
|
+
const refreshWaterfallObservers = () => {
|
|
20912
|
+
questionDisposer?.();
|
|
20913
|
+
approvalDisposer?.();
|
|
20914
|
+
questionDisposer = void 0;
|
|
20915
|
+
approvalDisposer = void 0;
|
|
20916
|
+
const question = register("user-questions/request", questionObserver, true);
|
|
20917
|
+
const approval = register("approval/request", approvalObserver, true);
|
|
20918
|
+
if (!question || !approval) {
|
|
20919
|
+
try {
|
|
20920
|
+
question?.();
|
|
20921
|
+
} catch {}
|
|
20922
|
+
try {
|
|
20923
|
+
approval?.();
|
|
20924
|
+
} catch {}
|
|
20925
|
+
available = false;
|
|
20926
|
+
return false;
|
|
20927
|
+
}
|
|
20928
|
+
questionDisposer = question;
|
|
20929
|
+
approvalDisposer = approval;
|
|
20930
|
+
return true;
|
|
20931
|
+
};
|
|
20932
|
+
const listen = (event, listener) => {
|
|
20933
|
+
const dispose = register(event, listener);
|
|
20934
|
+
if (!dispose) return false;
|
|
20935
|
+
disposers.push(dispose);
|
|
20936
|
+
return true;
|
|
20937
|
+
};
|
|
20938
|
+
const disable = () => {
|
|
20939
|
+
available = false;
|
|
20940
|
+
disposeLifecycleProjection();
|
|
20941
|
+
questionDisposer?.();
|
|
20942
|
+
approvalDisposer?.();
|
|
20943
|
+
questionDisposer = void 0;
|
|
20944
|
+
approvalDisposer = void 0;
|
|
20945
|
+
};
|
|
20946
|
+
if (available && refreshWaterfallObservers()) {
|
|
20947
|
+
const agentCreated = listen("agent/created", () => {
|
|
20948
|
+
if (!refreshWaterfallObservers()) disable();
|
|
20949
|
+
});
|
|
20950
|
+
const sessionEvents = listen("session/event", (session, event) => {
|
|
20951
|
+
if (event?.type === "turn/start" || event?.type === "agent/inbox/inserted" || event?.type === "agent/inbox/spliced") requests.invalidateSession(session.id, { preserveTimed: true });
|
|
20952
|
+
outcomes.onSessionEvent(session, event);
|
|
20953
|
+
if (event?.type === "user/message" && event?.data?.source?.kind === "user-question-reply") requests.queuedReply(session.id, event.data.source.callId);
|
|
20954
|
+
for (const callback of changeSubscribers) try {
|
|
20955
|
+
callback(session.id);
|
|
20956
|
+
} catch {}
|
|
20957
|
+
});
|
|
20958
|
+
const goalEvents = listen("goal/changed", (payload) => {
|
|
20959
|
+
outcomes.onGoal(payload);
|
|
20960
|
+
const id = payload?.agent?.id;
|
|
20961
|
+
if (id) for (const callback of changeSubscribers) try {
|
|
20962
|
+
callback(id);
|
|
20963
|
+
} catch {}
|
|
20964
|
+
});
|
|
20965
|
+
if (!agentCreated || !sessionEvents || !goalEvents) disable();
|
|
20966
|
+
listen("agent/inbox/inserted", (payload) => {
|
|
20967
|
+
const agent = payload?.agent;
|
|
20968
|
+
const message = payload?.message;
|
|
20969
|
+
if (agent?.id && message?.source?.kind === "user-question-reply" && typeof message.source.callId === "string") requests.queuedReply(agent.id, message.source.callId);
|
|
20970
|
+
if (agent?.id) {
|
|
20971
|
+
requests.invalidateSession(agent.id, { preserveTimed: true });
|
|
20972
|
+
outcomes.onSessionEvent(agent, { type: "agent/inbox/inserted" });
|
|
20973
|
+
}
|
|
20974
|
+
});
|
|
20975
|
+
listen("agent/error", (payload) => outcomes.onAgentError(payload));
|
|
20976
|
+
listen("agent/aborted", (payload) => {
|
|
20977
|
+
const id = payload?.agent?.id;
|
|
20978
|
+
if (id) {
|
|
20979
|
+
requests.invalidateSession(id);
|
|
20980
|
+
outcomes.onSessionEvent(payload.agent, {
|
|
20981
|
+
type: "turn/end",
|
|
20982
|
+
data: {
|
|
20983
|
+
turn: payload.turn,
|
|
20984
|
+
reason: {
|
|
20985
|
+
kind: "aborted",
|
|
20986
|
+
reason: payload.reason
|
|
20987
|
+
}
|
|
20988
|
+
}
|
|
20989
|
+
});
|
|
20990
|
+
}
|
|
20991
|
+
});
|
|
20992
|
+
} else disable();
|
|
20993
|
+
const reconcile = async (source) => {
|
|
20994
|
+
if (source.expiresAt <= Date.now()) return "stale";
|
|
20995
|
+
if (!available || closed) return "unverifiable";
|
|
20996
|
+
const liveProof = source.category === "question" || source.category === "plan" || source.category === "approval" ? requests.reconcileLive(source) : outcomes.reconcileLive(source);
|
|
20997
|
+
return reconciler.reconcile(source, liveProof);
|
|
20998
|
+
};
|
|
20999
|
+
return {
|
|
21000
|
+
get available() {
|
|
21001
|
+
return available;
|
|
21002
|
+
},
|
|
21003
|
+
subscribe(onSource, onChange) {
|
|
21004
|
+
if (closed) return () => void 0;
|
|
21005
|
+
subscribers.add(onSource);
|
|
21006
|
+
changeSubscribers.add(onChange);
|
|
21007
|
+
return () => {
|
|
21008
|
+
subscribers.delete(onSource);
|
|
21009
|
+
changeSubscribers.delete(onChange);
|
|
21010
|
+
};
|
|
21011
|
+
},
|
|
21012
|
+
reconcile,
|
|
21013
|
+
async eligible(source) {
|
|
21014
|
+
return await reconcile(source) === "eligible";
|
|
21015
|
+
},
|
|
21016
|
+
async sessionExists(sessionId) {
|
|
21017
|
+
if (!available || closed || !sessionId) return false;
|
|
21018
|
+
let observation;
|
|
21019
|
+
let exists = false;
|
|
21020
|
+
try {
|
|
21021
|
+
observation = await ctx.sessionQuery?.observeSession?.(sessionId, { projectionMode: "all" });
|
|
21022
|
+
exists = observation !== void 0;
|
|
21023
|
+
} catch {
|
|
21024
|
+
exists = false;
|
|
21025
|
+
} finally {
|
|
21026
|
+
try {
|
|
21027
|
+
observation?.[Symbol.dispose]();
|
|
21028
|
+
} catch {
|
|
21029
|
+
exists = false;
|
|
21030
|
+
}
|
|
21031
|
+
}
|
|
21032
|
+
return exists && available && !closed;
|
|
21033
|
+
},
|
|
21034
|
+
async resolveTarget(source) {
|
|
21035
|
+
if (!available) return void 0;
|
|
21036
|
+
const current = ctx.agents?.get?.(source.targetSessionId);
|
|
21037
|
+
return current ? {
|
|
21038
|
+
sessionId: current.id,
|
|
21039
|
+
interaction: "current"
|
|
21040
|
+
} : {
|
|
21041
|
+
sessionId: source.targetSessionId,
|
|
21042
|
+
interaction: "resolved"
|
|
21043
|
+
};
|
|
21044
|
+
},
|
|
21045
|
+
async close() {
|
|
21046
|
+
if (closed) return;
|
|
21047
|
+
closed = true;
|
|
21048
|
+
if (maintenanceTimer) clearInterval(maintenanceTimer);
|
|
21049
|
+
subscribers.clear();
|
|
21050
|
+
changeSubscribers.clear();
|
|
21051
|
+
requests.close();
|
|
21052
|
+
outcomes.close();
|
|
21053
|
+
questionDisposer?.();
|
|
21054
|
+
approvalDisposer?.();
|
|
21055
|
+
questionDisposer = void 0;
|
|
21056
|
+
approvalDisposer = void 0;
|
|
21057
|
+
for (const dispose of disposers.splice(0)) try {
|
|
21058
|
+
dispose();
|
|
21059
|
+
} catch {}
|
|
21060
|
+
disposeLifecycleProjection();
|
|
21061
|
+
}
|
|
21062
|
+
};
|
|
21063
|
+
}
|
|
21064
|
+
//#endregion
|
|
21065
|
+
//#region src/mobile-push/endpoint.ts
|
|
21066
|
+
const MAX_ENDPOINT_BYTES = 2048;
|
|
21067
|
+
const APPLE_SUFFIX = ".push.apple.com";
|
|
21068
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/u;
|
|
21069
|
+
/** Canonical URL identity for push endpoint ownership; policy checks live elsewhere. */
|
|
21070
|
+
function canonicalPushEndpoint(value) {
|
|
21071
|
+
try {
|
|
21072
|
+
return new URL(value).href;
|
|
21073
|
+
} catch {
|
|
21074
|
+
throw new TypeError("push_endpoint_invalid");
|
|
21075
|
+
}
|
|
21076
|
+
}
|
|
21077
|
+
function invalid() {
|
|
21078
|
+
throw new TypeError("push_subscription_invalid");
|
|
21079
|
+
}
|
|
21080
|
+
function decodeBase64url(value) {
|
|
21081
|
+
if (typeof value !== "string" || !BASE64URL.test(value)) return invalid();
|
|
21082
|
+
const decoded = Buffer.from(value, "base64url");
|
|
21083
|
+
if (decoded.toString("base64url") !== value) return invalid();
|
|
21084
|
+
return decoded;
|
|
21085
|
+
}
|
|
21086
|
+
function validateEndpoint(value) {
|
|
21087
|
+
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_ENDPOINT_BYTES) return invalid();
|
|
21088
|
+
let url;
|
|
21089
|
+
try {
|
|
21090
|
+
url = new URL(value);
|
|
21091
|
+
} catch {
|
|
21092
|
+
return invalid();
|
|
21093
|
+
}
|
|
21094
|
+
const hostname = url.hostname.toLowerCase();
|
|
21095
|
+
const authority = /^https:\/\/([^/?#]*)/iu.exec(value)?.[1] ?? "";
|
|
21096
|
+
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();
|
|
21097
|
+
const canonical = canonicalPushEndpoint(value);
|
|
21098
|
+
if (Buffer.byteLength(canonical, "utf8") > MAX_ENDPOINT_BYTES) return invalid();
|
|
21099
|
+
return canonical;
|
|
21100
|
+
}
|
|
21101
|
+
function validateP256PublicKey(value) {
|
|
21102
|
+
const bytes = decodeBase64url(value);
|
|
21103
|
+
if (bytes.length !== 65 || bytes[0] !== 4) return invalid();
|
|
21104
|
+
try {
|
|
21105
|
+
createPublicKey({
|
|
21106
|
+
key: {
|
|
21107
|
+
kty: "EC",
|
|
21108
|
+
crv: "P-256",
|
|
21109
|
+
x: bytes.subarray(1, 33).toString("base64url"),
|
|
21110
|
+
y: bytes.subarray(33, 65).toString("base64url")
|
|
21111
|
+
},
|
|
21112
|
+
format: "jwk"
|
|
21113
|
+
});
|
|
21114
|
+
} catch {
|
|
21115
|
+
return invalid();
|
|
21116
|
+
}
|
|
21117
|
+
return value;
|
|
21118
|
+
}
|
|
21119
|
+
/** Validate an untrusted browser subscription before persisting or sending it. */
|
|
21120
|
+
function validatePushSubscription(value) {
|
|
21121
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return invalid();
|
|
21122
|
+
const input = value;
|
|
21123
|
+
if (input.keys === null || typeof input.keys !== "object" || Array.isArray(input.keys)) return invalid();
|
|
21124
|
+
const keys = input.keys;
|
|
21125
|
+
const p256dh = validateP256PublicKey(keys.p256dh);
|
|
21126
|
+
if (decodeBase64url(keys.auth).length !== 16) return invalid();
|
|
21127
|
+
return Object.freeze({
|
|
21128
|
+
endpoint: validateEndpoint(input.endpoint),
|
|
21129
|
+
keys: Object.freeze({
|
|
21130
|
+
p256dh,
|
|
21131
|
+
auth: keys.auth
|
|
21132
|
+
})
|
|
21133
|
+
});
|
|
21134
|
+
}
|
|
21135
|
+
function ipv6Value(address) {
|
|
21136
|
+
const [leftText = "", rightText] = address.toLowerCase().split("::");
|
|
21137
|
+
const left = leftText === "" ? [] : leftText.split(":");
|
|
21138
|
+
const right = rightText === void 0 || rightText === "" ? [] : rightText.split(":");
|
|
21139
|
+
const omitted = 8 - left.length - right.length;
|
|
21140
|
+
const groups = [
|
|
21141
|
+
...left,
|
|
21142
|
+
...Array.from({ length: omitted }, () => "0"),
|
|
21143
|
+
...right
|
|
21144
|
+
];
|
|
21145
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/u.test(group))) throw new Error("invalid IPv6");
|
|
21146
|
+
return groups.reduce((n, group) => n << 16n | BigInt(`0x${group}`), 0n);
|
|
21147
|
+
}
|
|
21148
|
+
/** Reject non-global, special-purpose and IPv4-mapped DNS answers. */
|
|
21149
|
+
function isGloballyRoutableAddress(address) {
|
|
21150
|
+
const family = isIP(address);
|
|
21151
|
+
if (family === 4) return isGloballyRoutableIpv4(address);
|
|
21152
|
+
if (family !== 6 || address.includes("%") || address.includes(".")) return false;
|
|
21153
|
+
try {
|
|
21154
|
+
const value = ipv6Value(address);
|
|
21155
|
+
const first16 = value >> 112n;
|
|
21156
|
+
if (value >> 125n !== 1n) return false;
|
|
21157
|
+
if (first16 === 8193n && value >> 105n === 8193n << 7n) return false;
|
|
21158
|
+
if (first16 === 8193n && value >> 96n === 536939960n) return false;
|
|
21159
|
+
if (first16 === 8194n) return false;
|
|
21160
|
+
if (first16 === 16383n && (value >> 108n & 15n) === 0n) return false;
|
|
21161
|
+
return true;
|
|
21162
|
+
} catch {
|
|
21163
|
+
return false;
|
|
21164
|
+
}
|
|
21165
|
+
}
|
|
21166
|
+
async function systemResolver(hostname) {
|
|
21167
|
+
return (await lookup(hostname, {
|
|
21168
|
+
all: true,
|
|
21169
|
+
verbatim: true
|
|
21170
|
+
})).filter((item) => item.family === 4 || item.family === 6);
|
|
21171
|
+
}
|
|
21172
|
+
/** Resolve and vet every answer before a caller opens a socket. */
|
|
21173
|
+
async function resolveAppleEndpoint(endpoint, resolver = systemResolver) {
|
|
21174
|
+
const url = new URL(validateEndpoint(endpoint));
|
|
21175
|
+
const addresses = await resolver(url.hostname);
|
|
21176
|
+
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");
|
|
21177
|
+
return Object.freeze({
|
|
21178
|
+
url,
|
|
21179
|
+
addresses: Object.freeze([...addresses])
|
|
21180
|
+
});
|
|
21181
|
+
}
|
|
21182
|
+
//#endregion
|
|
21183
|
+
//#region src/mobile-push/store.ts
|
|
21184
|
+
const MAX_STATE_BYTES = 4194304;
|
|
21185
|
+
const emptySnapshot = () => ({
|
|
21186
|
+
version: 1,
|
|
21187
|
+
ownerGenerations: [],
|
|
21188
|
+
registrations: [],
|
|
21189
|
+
pending: [],
|
|
21190
|
+
receipts: [],
|
|
21191
|
+
references: []
|
|
21192
|
+
});
|
|
21193
|
+
async function ensurePrivateDirectory(directory) {
|
|
21194
|
+
const absolute = resolve(directory);
|
|
21195
|
+
await mkdir(absolute, {
|
|
21196
|
+
recursive: true,
|
|
21197
|
+
mode: 448
|
|
21198
|
+
});
|
|
21199
|
+
const stat = await lstat(absolute);
|
|
21200
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("push state parent must be a managed non-symlink directory");
|
|
21201
|
+
if (process.platform !== "win32" && (stat.mode & 63) !== 0) throw new Error("push state parent permissions must be owner-only");
|
|
21202
|
+
}
|
|
21203
|
+
function object(value) {
|
|
21204
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21205
|
+
}
|
|
21206
|
+
function list(value) {
|
|
21207
|
+
return Array.isArray(value);
|
|
21208
|
+
}
|
|
21209
|
+
function validKey(value) {
|
|
21210
|
+
return object(value) && only(value, [
|
|
21211
|
+
"publicKey",
|
|
21212
|
+
"privateKey",
|
|
21213
|
+
"fingerprint"
|
|
21214
|
+
]) && typeof value.publicKey === "string" && value.publicKey.length > 0 && typeof value.privateKey === "string" && value.privateKey.length > 0 && typeof value.fingerprint === "string" && value.fingerprint.length > 0;
|
|
21215
|
+
}
|
|
21216
|
+
function owner(value) {
|
|
21217
|
+
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;
|
|
21218
|
+
}
|
|
21219
|
+
function recipient(value) {
|
|
21220
|
+
return owner(value) && typeof value.installationId === "string" && value.installationId.length > 0;
|
|
21221
|
+
}
|
|
21222
|
+
function only(value, keys) {
|
|
21223
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
21224
|
+
}
|
|
21225
|
+
function validSource(value) {
|
|
21226
|
+
if (!object(value) || !only(value, [
|
|
21227
|
+
"sourceId",
|
|
21228
|
+
"sessionId",
|
|
21229
|
+
"targetSessionId",
|
|
21230
|
+
"category",
|
|
21231
|
+
"createdAt",
|
|
21232
|
+
"expiresAt",
|
|
21233
|
+
"lifetime",
|
|
21234
|
+
"turn",
|
|
21235
|
+
"callId",
|
|
21236
|
+
"goal"
|
|
21237
|
+
])) return false;
|
|
21238
|
+
return [
|
|
21239
|
+
value.sourceId,
|
|
21240
|
+
value.sessionId,
|
|
21241
|
+
value.targetSessionId
|
|
21242
|
+
].every((v) => typeof v === "string" && v.length > 0 && v.length <= 4096) && [
|
|
21243
|
+
"question",
|
|
21244
|
+
"plan",
|
|
21245
|
+
"approval",
|
|
21246
|
+
"response",
|
|
21247
|
+
"goal",
|
|
21248
|
+
"failure"
|
|
21249
|
+
].includes(String(value.category)) && Number.isFinite(value.createdAt) && Number.isFinite(value.expiresAt) && [
|
|
21250
|
+
"live",
|
|
21251
|
+
"projected",
|
|
21252
|
+
"committed"
|
|
21253
|
+
].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));
|
|
21254
|
+
}
|
|
21255
|
+
function scopedCounts(rows, scope, maximum = 1024) {
|
|
21256
|
+
const counts = /* @__PURE__ */ new Map();
|
|
21257
|
+
for (const row of rows) {
|
|
21258
|
+
const key = scope(row);
|
|
21259
|
+
const next = (counts.get(key) ?? 0) + 1;
|
|
21260
|
+
counts.set(key, next);
|
|
21261
|
+
if (next > maximum) return false;
|
|
21262
|
+
}
|
|
21263
|
+
return true;
|
|
21264
|
+
}
|
|
21265
|
+
function registration(value) {
|
|
21266
|
+
return recipient(value) && object(value) && only(value, [
|
|
21267
|
+
"scopeId",
|
|
21268
|
+
"deviceId",
|
|
21269
|
+
"origin",
|
|
21270
|
+
"installationId",
|
|
21271
|
+
"subscription",
|
|
21272
|
+
"locale",
|
|
21273
|
+
"preferences",
|
|
21274
|
+
"generation",
|
|
21275
|
+
"keyFingerprint",
|
|
21276
|
+
"enabled",
|
|
21277
|
+
"health"
|
|
21278
|
+
]) && 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, [
|
|
21279
|
+
"question",
|
|
21280
|
+
"plan",
|
|
21281
|
+
"approval",
|
|
21282
|
+
"response",
|
|
21283
|
+
"goal",
|
|
21284
|
+
"failure"
|
|
21285
|
+
]) && [
|
|
21286
|
+
"question",
|
|
21287
|
+
"plan",
|
|
21288
|
+
"approval",
|
|
21289
|
+
"response",
|
|
21290
|
+
"goal",
|
|
21291
|
+
"failure"
|
|
21292
|
+
].every((k) => typeof value.preferences[k] === "boolean") && Number.isSafeInteger(value.generation) && Number(value.generation) > 0 && typeof value.keyFingerprint === "string" && typeof value.enabled === "boolean" && [
|
|
21293
|
+
"ready",
|
|
21294
|
+
"suspended",
|
|
21295
|
+
"repair-required"
|
|
21296
|
+
].includes(String(value.health));
|
|
21297
|
+
}
|
|
21298
|
+
function parsePushSnapshot(value) {
|
|
21299
|
+
if (!object(value) || !only(value, [
|
|
21300
|
+
"version",
|
|
21301
|
+
"keys",
|
|
21302
|
+
"ownerGenerations",
|
|
21303
|
+
"ownerGenerationFloor",
|
|
21304
|
+
"registrations",
|
|
21305
|
+
"pending",
|
|
21306
|
+
"receipts",
|
|
21307
|
+
"references"
|
|
21308
|
+
]) || value.version !== 1) throw new Error("push state has an unsupported version");
|
|
21309
|
+
for (const field of [
|
|
21310
|
+
"ownerGenerations",
|
|
21311
|
+
"registrations",
|
|
21312
|
+
"pending",
|
|
21313
|
+
"receipts",
|
|
21314
|
+
"references"
|
|
21315
|
+
]) if (!list(value[field])) throw new Error(`push state ${field} must be an array`);
|
|
21316
|
+
if (value.ownerGenerationFloor !== void 0 && (!Number.isSafeInteger(value.ownerGenerationFloor) || Number(value.ownerGenerationFloor) < 0)) throw new Error("push state has an invalid owner generation floor");
|
|
21317
|
+
if (value.keys !== void 0 && !validKey(value.keys)) throw new Error("push state contains invalid key material");
|
|
21318
|
+
const owners = value.ownerGenerations;
|
|
21319
|
+
const registrations = value.registrations;
|
|
21320
|
+
const pending = value.pending;
|
|
21321
|
+
const receipts = value.receipts;
|
|
21322
|
+
const references = value.references;
|
|
21323
|
+
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");
|
|
21324
|
+
if (!owners.every((o) => owner(o) && only(o, [
|
|
21325
|
+
"scopeId",
|
|
21326
|
+
"deviceId",
|
|
21327
|
+
"origin",
|
|
21328
|
+
"generation"
|
|
21329
|
+
]) && Number.isSafeInteger(o.generation) && Number(o.generation) >= 0) || !registrations.every(registration) || !pending.every((i) => object(i) && only(i, [
|
|
21330
|
+
"id",
|
|
21331
|
+
"key",
|
|
21332
|
+
"source",
|
|
21333
|
+
"ownerGeneration",
|
|
21334
|
+
"recipientGeneration",
|
|
21335
|
+
"attempts",
|
|
21336
|
+
"nextAttemptAt"
|
|
21337
|
+
]) && typeof i.id === "string" && recipient(i.key) && object(i.key) && only(i.key, [
|
|
21338
|
+
"scopeId",
|
|
21339
|
+
"deviceId",
|
|
21340
|
+
"origin",
|
|
21341
|
+
"installationId"
|
|
21342
|
+
]) && 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, [
|
|
21343
|
+
"sourceId",
|
|
21344
|
+
"key",
|
|
21345
|
+
"recipientGeneration",
|
|
21346
|
+
"expiresAt"
|
|
21347
|
+
]) && typeof r.sourceId === "string" && recipient(r.key) && object(r.key) && only(r.key, [
|
|
21348
|
+
"scopeId",
|
|
21349
|
+
"deviceId",
|
|
21350
|
+
"origin",
|
|
21351
|
+
"installationId"
|
|
21352
|
+
]) && Number.isSafeInteger(r.recipientGeneration) && Number.isFinite(r.expiresAt)) || !references.every((r) => object(r) && only(r, [
|
|
21353
|
+
"reference",
|
|
21354
|
+
"key",
|
|
21355
|
+
"source",
|
|
21356
|
+
"expiresAt"
|
|
21357
|
+
]) && typeof r.reference === "string" && recipient(r.key) && object(r.key) && only(r.key, [
|
|
21358
|
+
"scopeId",
|
|
21359
|
+
"deviceId",
|
|
21360
|
+
"origin",
|
|
21361
|
+
"installationId"
|
|
21362
|
+
]) && validSource(r.source) && r.expiresAt === r.source.expiresAt)) throw new Error("push state contains invalid records");
|
|
21363
|
+
const ownerKeys = owners.map((o) => JSON.stringify([
|
|
21364
|
+
o.scopeId,
|
|
21365
|
+
o.deviceId,
|
|
21366
|
+
o.origin
|
|
21367
|
+
]));
|
|
21368
|
+
const validRegistrations = registrations;
|
|
21369
|
+
const regKeys = validRegistrations.map((reg) => JSON.stringify([
|
|
21370
|
+
reg.scopeId,
|
|
21371
|
+
reg.deviceId,
|
|
21372
|
+
reg.origin,
|
|
21373
|
+
reg.installationId
|
|
21374
|
+
]));
|
|
21375
|
+
const endpoints = validRegistrations.map((reg) => canonicalPushEndpoint(reg.subscription.endpoint));
|
|
21376
|
+
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");
|
|
21377
|
+
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");
|
|
21378
|
+
return structuredClone(value);
|
|
21379
|
+
}
|
|
21380
|
+
var JsonPushStore = class {
|
|
21381
|
+
file;
|
|
21382
|
+
constructor(file) {
|
|
21383
|
+
this.file = file;
|
|
21384
|
+
}
|
|
21385
|
+
async load() {
|
|
21386
|
+
try {
|
|
21387
|
+
const parent = await lstat(dirname(this.file));
|
|
21388
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error("push state parent must be a managed non-symlink directory");
|
|
21389
|
+
if (process.platform !== "win32" && (parent.mode & 63) !== 0) throw new Error("push state parent permissions must be owner-only");
|
|
21390
|
+
} catch (error) {
|
|
21391
|
+
if (error.code === "ENOENT") return emptySnapshot();
|
|
21392
|
+
throw error;
|
|
21393
|
+
}
|
|
21394
|
+
let info;
|
|
21395
|
+
try {
|
|
21396
|
+
info = await lstat(this.file);
|
|
21397
|
+
} catch (error) {
|
|
21398
|
+
if (error.code === "ENOENT") return emptySnapshot();
|
|
21399
|
+
throw error;
|
|
21400
|
+
}
|
|
21401
|
+
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");
|
|
21402
|
+
await restrictPrivateFile(this.file);
|
|
21403
|
+
let parsed;
|
|
21404
|
+
try {
|
|
21405
|
+
parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
21406
|
+
} catch (error) {
|
|
21407
|
+
throw new Error("push state is not valid JSON", { cause: error });
|
|
21408
|
+
}
|
|
21409
|
+
return parsePushSnapshot(parsed);
|
|
21410
|
+
}
|
|
21411
|
+
async save(snapshot) {
|
|
21412
|
+
const safe = parsePushSnapshot(snapshot);
|
|
21413
|
+
const serialized = `${JSON.stringify(safe)}\n`;
|
|
21414
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error("push state exceeds 4 MiB");
|
|
21415
|
+
const directory = dirname(this.file);
|
|
21416
|
+
await ensurePrivateDirectory(directory);
|
|
21417
|
+
try {
|
|
21418
|
+
const current = await lstat(this.file);
|
|
21419
|
+
if (!current.isFile() || current.isSymbolicLink()) throw new Error("push state target must remain a regular file");
|
|
21420
|
+
} catch (error) {
|
|
21421
|
+
if (error.code !== "ENOENT") throw error;
|
|
21422
|
+
}
|
|
21423
|
+
const temporary = join(directory, `.${basename(this.file)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
21424
|
+
try {
|
|
21425
|
+
await writeFile(temporary, "", {
|
|
21426
|
+
encoding: "utf8",
|
|
21427
|
+
flag: "wx",
|
|
21428
|
+
mode: 384
|
|
21429
|
+
});
|
|
21430
|
+
await restrictPrivateFile(temporary);
|
|
21431
|
+
await writeFile(temporary, serialized, {
|
|
21432
|
+
encoding: "utf8",
|
|
21433
|
+
flag: "r+"
|
|
21434
|
+
});
|
|
21435
|
+
await rename(temporary, this.file);
|
|
21436
|
+
} catch (error) {
|
|
21437
|
+
try {
|
|
21438
|
+
await rm(temporary, { force: true });
|
|
21439
|
+
} catch (cleanupError) {
|
|
21440
|
+
throw new AggregateError([error, cleanupError], "push state write and cleanup both failed");
|
|
21441
|
+
}
|
|
21442
|
+
throw error;
|
|
21443
|
+
}
|
|
21444
|
+
}
|
|
21445
|
+
};
|
|
21446
|
+
//#endregion
|
|
21447
|
+
//#region src/mobile-push/registry.ts
|
|
21448
|
+
const empty = () => ({
|
|
21449
|
+
version: 1,
|
|
21450
|
+
ownerGenerations: [],
|
|
21451
|
+
registrations: [],
|
|
21452
|
+
pending: [],
|
|
21453
|
+
receipts: [],
|
|
21454
|
+
references: []
|
|
21455
|
+
});
|
|
21456
|
+
const idOfOwner = (owner) => JSON.stringify([
|
|
21457
|
+
owner.scopeId,
|
|
21458
|
+
owner.deviceId,
|
|
21459
|
+
owner.origin
|
|
21460
|
+
]);
|
|
21461
|
+
const idOfKey = (key) => JSON.stringify([
|
|
21462
|
+
key.scopeId,
|
|
21463
|
+
key.deviceId,
|
|
21464
|
+
key.origin,
|
|
21465
|
+
key.installationId
|
|
21466
|
+
]);
|
|
21467
|
+
const sameOwner = (a, b) => idOfOwner(a) === idOfOwner(b);
|
|
21468
|
+
function vapidFingerprint(keys) {
|
|
21469
|
+
const rawPublic = Buffer.from(keys.publicKey, "base64url");
|
|
21470
|
+
if (rawPublic.length !== 65 || rawPublic[0] !== 4 || rawPublic.toString("base64url") !== keys.publicKey) throw new Error("invalid uncompressed VAPID public key");
|
|
21471
|
+
const privateBytes = Buffer.from(keys.privateKey, "base64url");
|
|
21472
|
+
if (privateBytes.length !== 32 || privateBytes.toString("base64url") !== keys.privateKey) throw new Error("invalid VAPID private key");
|
|
21473
|
+
const ecdh = createECDH("prime256v1");
|
|
21474
|
+
ecdh.setPrivateKey(privateBytes);
|
|
21475
|
+
if (!ecdh.getPublicKey(void 0, "uncompressed").equals(rawPublic)) throw new Error("VAPID public and private keys do not match");
|
|
21476
|
+
return createHash("sha256").update(rawPublic).digest("hex");
|
|
21477
|
+
}
|
|
21478
|
+
const fingerprint = vapidFingerprint;
|
|
21479
|
+
const defaultPreferences = {
|
|
21480
|
+
question: true,
|
|
21481
|
+
plan: true,
|
|
21482
|
+
approval: true,
|
|
21483
|
+
response: true,
|
|
21484
|
+
goal: true,
|
|
21485
|
+
failure: true
|
|
21486
|
+
};
|
|
21487
|
+
function clone(value) {
|
|
21488
|
+
return structuredClone(value);
|
|
21489
|
+
}
|
|
21490
|
+
function boundedText(value, label) {
|
|
21491
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) throw new Error(`invalid ${label}`);
|
|
21492
|
+
}
|
|
21493
|
+
/** Serializes and durably commits all push ownership and consent mutations. */
|
|
21494
|
+
var PushRegistry = class {
|
|
21495
|
+
store;
|
|
21496
|
+
createKeys;
|
|
21497
|
+
state = empty();
|
|
21498
|
+
initialized = false;
|
|
21499
|
+
closed = false;
|
|
21500
|
+
queue = Promise.resolve();
|
|
21501
|
+
constructor(store, createKeys) {
|
|
21502
|
+
this.store = store;
|
|
21503
|
+
this.createKeys = createKeys;
|
|
21504
|
+
}
|
|
21505
|
+
serialize(operation) {
|
|
21506
|
+
const result = this.queue.then(() => {
|
|
21507
|
+
if (this.closed) throw new Error("push registry is closed");
|
|
21508
|
+
return operation();
|
|
21509
|
+
});
|
|
21510
|
+
this.queue = result.catch(() => void 0);
|
|
21511
|
+
return result;
|
|
21512
|
+
}
|
|
21513
|
+
assertReady() {
|
|
21514
|
+
if (!this.initialized) throw new Error("push registry is not initialized");
|
|
21515
|
+
}
|
|
21516
|
+
ownerGeneration(owner) {
|
|
21517
|
+
return Math.max(this.state.ownerGenerationFloor ?? 0, this.state.ownerGenerations.find((item) => sameOwner(item, owner))?.generation ?? 0);
|
|
21518
|
+
}
|
|
21519
|
+
advanceOwnerGenerationFloor(snapshot, removedRegistrationGenerations = []) {
|
|
21520
|
+
let maximum = snapshot.ownerGenerationFloor ?? 0;
|
|
21521
|
+
for (const row of snapshot.ownerGenerations) maximum = Math.max(maximum, row.generation);
|
|
21522
|
+
for (const generation of removedRegistrationGenerations) maximum = Math.max(maximum, generation);
|
|
21523
|
+
if (!Number.isSafeInteger(maximum) || maximum >= Number.MAX_SAFE_INTEGER) throw new Error("owner generation floor exceeds safe integer range");
|
|
21524
|
+
return {
|
|
21525
|
+
...snapshot,
|
|
21526
|
+
ownerGenerationFloor: maximum + 1
|
|
21527
|
+
};
|
|
21528
|
+
}
|
|
21529
|
+
async commit(next) {
|
|
21530
|
+
await this.store.save(next);
|
|
21531
|
+
this.state = next;
|
|
21532
|
+
}
|
|
21533
|
+
putOwnerGeneration(next, owner, generation) {
|
|
21534
|
+
const rows = [...next.ownerGenerations];
|
|
21535
|
+
const index = rows.findIndex((item) => sameOwner(item, owner));
|
|
21536
|
+
if (index >= 0) rows[index] = {
|
|
21537
|
+
...rows[index],
|
|
21538
|
+
generation
|
|
21539
|
+
};
|
|
21540
|
+
else {
|
|
21541
|
+
if (rows.filter((item) => item.scopeId === owner.scopeId).length >= 1024) throw new Error("owner generation limit reached for authorization scope");
|
|
21542
|
+
rows.push({
|
|
21543
|
+
scopeId: owner.scopeId,
|
|
21544
|
+
deviceId: owner.deviceId,
|
|
21545
|
+
origin: owner.origin,
|
|
21546
|
+
generation
|
|
21547
|
+
});
|
|
21548
|
+
}
|
|
21549
|
+
return {
|
|
21550
|
+
...next,
|
|
21551
|
+
ownerGenerations: rows
|
|
21552
|
+
};
|
|
21553
|
+
}
|
|
21554
|
+
async initialize() {
|
|
21555
|
+
await this.serialize(async () => {
|
|
21556
|
+
let loaded = await this.store.load();
|
|
21557
|
+
let keyIntegrity = false;
|
|
21558
|
+
try {
|
|
21559
|
+
keyIntegrity = !!loaded.keys && loaded.keys.fingerprint === fingerprint(loaded.keys);
|
|
21560
|
+
} catch (_error) {
|
|
21561
|
+
keyIntegrity = false;
|
|
21562
|
+
}
|
|
21563
|
+
if (!keyIntegrity) {
|
|
21564
|
+
if (loaded.registrations.length) {
|
|
21565
|
+
loaded = {
|
|
21566
|
+
...loaded,
|
|
21567
|
+
registrations: loaded.registrations.map((reg) => ({
|
|
21568
|
+
...reg,
|
|
21569
|
+
health: "repair-required"
|
|
21570
|
+
}))
|
|
21571
|
+
};
|
|
21572
|
+
await this.store.save(loaded);
|
|
21573
|
+
} else {
|
|
21574
|
+
const keys = this.createKeys();
|
|
21575
|
+
if (!keys || typeof keys.publicKey !== "string" || !keys.publicKey || typeof keys.privateKey !== "string" || !keys.privateKey) throw new Error("key factory returned invalid VAPID keys");
|
|
21576
|
+
loaded = {
|
|
21577
|
+
...loaded,
|
|
21578
|
+
keys: {
|
|
21579
|
+
...keys,
|
|
21580
|
+
fingerprint: fingerprint(keys)
|
|
21581
|
+
}
|
|
21582
|
+
};
|
|
21583
|
+
await this.store.save(loaded);
|
|
21584
|
+
}
|
|
21585
|
+
}
|
|
21586
|
+
this.state = clone(loaded);
|
|
21587
|
+
this.initialized = true;
|
|
21588
|
+
});
|
|
21589
|
+
}
|
|
21590
|
+
async publicKey() {
|
|
21591
|
+
this.assertReady();
|
|
21592
|
+
if (!this.state.keys || this.state.registrations.some((reg) => reg.health === "repair-required")) throw new Error("push VAPID keys require repair");
|
|
21593
|
+
return this.state.keys.publicKey;
|
|
21594
|
+
}
|
|
21595
|
+
async config(owner, installationId) {
|
|
21596
|
+
this.assertReady();
|
|
21597
|
+
const key = {
|
|
21598
|
+
...owner,
|
|
21599
|
+
installationId
|
|
21600
|
+
};
|
|
21601
|
+
const registration = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21602
|
+
const keyUsable = !!this.state.keys && !this.state.registrations.some((row) => row.health === "repair-required");
|
|
21603
|
+
return {
|
|
21604
|
+
available: keyUsable,
|
|
21605
|
+
...keyUsable ? {
|
|
21606
|
+
publicKey: this.state.keys.publicKey,
|
|
21607
|
+
keyFingerprint: this.state.keys.fingerprint
|
|
21608
|
+
} : {},
|
|
21609
|
+
installationId,
|
|
21610
|
+
ownerGeneration: this.ownerGeneration(owner),
|
|
21611
|
+
registrationGeneration: registration?.generation ?? 0,
|
|
21612
|
+
status: registration?.health === "repair-required" ? "repair-required" : registration?.health === "suspended" ? "suspended" : registration?.enabled ? "enabled" : "disabled",
|
|
21613
|
+
preferences: clone(registration?.preferences ?? defaultPreferences)
|
|
21614
|
+
};
|
|
21615
|
+
}
|
|
21616
|
+
/** Server-internal persistence snapshot; includes private VAPID material and is not a public config projection. */
|
|
21617
|
+
snapshot() {
|
|
21618
|
+
this.assertReady();
|
|
21619
|
+
return clone(this.state);
|
|
21620
|
+
}
|
|
21621
|
+
async transact(update) {
|
|
21622
|
+
await this.serialize(async () => {
|
|
21623
|
+
this.assertReady();
|
|
21624
|
+
const next = update(clone(this.state));
|
|
21625
|
+
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");
|
|
21626
|
+
const validated = {
|
|
21627
|
+
...next,
|
|
21628
|
+
...this.pruneExpired(next)
|
|
21629
|
+
};
|
|
21630
|
+
await this.commit(validated);
|
|
21631
|
+
});
|
|
21632
|
+
}
|
|
21633
|
+
async upsert(owner, input) {
|
|
21634
|
+
return this.serialize(async () => {
|
|
21635
|
+
this.assertReady();
|
|
21636
|
+
boundedText(owner.scopeId, "scope");
|
|
21637
|
+
boundedText(owner.deviceId, "device");
|
|
21638
|
+
boundedText(owner.origin, "origin");
|
|
21639
|
+
boundedText(input.installationId, "installation");
|
|
21640
|
+
boundedText(input.keyFingerprint, "key fingerprint");
|
|
21641
|
+
boundedText(input.subscription?.endpoint, "subscription endpoint");
|
|
21642
|
+
const canonicalEndpoint = canonicalPushEndpoint(input.subscription.endpoint);
|
|
21643
|
+
const normalizedSubscription = {
|
|
21644
|
+
...input.subscription,
|
|
21645
|
+
endpoint: canonicalEndpoint
|
|
21646
|
+
};
|
|
21647
|
+
if (!normalizedSubscription.keys?.p256dh || !normalizedSubscription.keys.auth) throw new Error("invalid subscription keys");
|
|
21648
|
+
if (!this.state.keys || input.keyFingerprint !== this.state.keys.fingerprint) throw new Error("subscription VAPID key fingerprint conflict");
|
|
21649
|
+
const key = {
|
|
21650
|
+
...owner,
|
|
21651
|
+
installationId: input.installationId
|
|
21652
|
+
};
|
|
21653
|
+
const current = this.state.registrations.find((reg) => idOfKey(reg) === idOfKey(key));
|
|
21654
|
+
const sameEndpoint = this.state.registrations.find((reg) => canonicalPushEndpoint(reg.subscription.endpoint) === canonicalEndpoint);
|
|
21655
|
+
if (sameEndpoint && !sameOwner(sameEndpoint, owner)) throw new Error("subscription endpoint is already owned by another device (conflict)");
|
|
21656
|
+
if (sameEndpoint && !current) {
|
|
21657
|
+
if (sameEndpoint.keyFingerprint !== input.keyFingerprint || !sameEndpoint.enabled) throw new Error("canonical subscription recovery conflict");
|
|
21658
|
+
return { registration: clone(sameEndpoint) };
|
|
21659
|
+
}
|
|
21660
|
+
const ownerGeneration = this.ownerGeneration(owner);
|
|
21661
|
+
if (input.ownerGeneration !== ownerGeneration || input.registrationGeneration !== (current?.generation ?? 0)) throw new Error("stale push mutation generation");
|
|
21662
|
+
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) };
|
|
21663
|
+
if (!current && this.state.registrations.filter((reg) => reg.scopeId === owner.scopeId).length >= 256) throw new Error("push registration limit reached for scope");
|
|
21664
|
+
if (!current && this.state.registrations.filter((reg) => sameOwner(reg, owner)).length >= 4) throw new Error("push registration limit reached for device");
|
|
21665
|
+
const generation = current ? current.generation + 1 : ownerGeneration + 1;
|
|
21666
|
+
const registration = {
|
|
21667
|
+
...key,
|
|
21668
|
+
subscription: clone(normalizedSubscription),
|
|
21669
|
+
locale: input.locale,
|
|
21670
|
+
preferences: clone(input.preferences),
|
|
21671
|
+
generation,
|
|
21672
|
+
keyFingerprint: input.keyFingerprint,
|
|
21673
|
+
enabled: true,
|
|
21674
|
+
health: "ready"
|
|
21675
|
+
};
|
|
21676
|
+
let next = {
|
|
21677
|
+
...this.state,
|
|
21678
|
+
registrations: [...this.state.registrations.filter((reg) => idOfKey(reg) !== idOfKey(key)), registration]
|
|
21679
|
+
};
|
|
21680
|
+
next = this.pruneExpired(next);
|
|
21681
|
+
if (next.pending.filter((intent) => intent.key.scopeId === owner.scopeId).length > 256) throw new Error("pending push limit reached for scope");
|
|
21682
|
+
await this.commit(next);
|
|
21683
|
+
return {
|
|
21684
|
+
registration: clone(registration),
|
|
21685
|
+
mutationGeneration: generation
|
|
21686
|
+
};
|
|
21687
|
+
});
|
|
21688
|
+
}
|
|
21689
|
+
async preferences(key, value, guard) {
|
|
21690
|
+
return this.serialize(async () => {
|
|
21691
|
+
this.assertReady();
|
|
21692
|
+
const reg = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21693
|
+
if (!reg || guard.ownerGeneration !== this.ownerGeneration(key) || guard.registrationGeneration !== reg.generation) throw new Error("stale push mutation generation");
|
|
21694
|
+
if (!value || ![
|
|
21695
|
+
"question",
|
|
21696
|
+
"plan",
|
|
21697
|
+
"approval",
|
|
21698
|
+
"response",
|
|
21699
|
+
"goal",
|
|
21700
|
+
"failure"
|
|
21701
|
+
].every((k) => typeof value[k] === "boolean")) throw new Error("invalid push preferences");
|
|
21702
|
+
const updated = {
|
|
21703
|
+
...reg,
|
|
21704
|
+
preferences: clone(value),
|
|
21705
|
+
generation: reg.generation + 1
|
|
21706
|
+
};
|
|
21707
|
+
await this.commit({
|
|
21708
|
+
...this.state,
|
|
21709
|
+
registrations: this.state.registrations.map((row) => idOfKey(row) === idOfKey(key) ? updated : row)
|
|
21710
|
+
});
|
|
21711
|
+
return clone(updated);
|
|
21712
|
+
});
|
|
21713
|
+
}
|
|
21714
|
+
async disable(key, ifGeneration) {
|
|
21715
|
+
await this.serialize(async () => {
|
|
21716
|
+
this.assertReady();
|
|
21717
|
+
const reg = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21718
|
+
if (ifGeneration !== void 0 && reg?.generation !== ifGeneration) return;
|
|
21719
|
+
let next = this.state;
|
|
21720
|
+
if (reg) next = {
|
|
21721
|
+
...next,
|
|
21722
|
+
registrations: next.registrations.map((row) => idOfKey(row) === idOfKey(key) ? {
|
|
21723
|
+
...row,
|
|
21724
|
+
enabled: false,
|
|
21725
|
+
generation: row.generation + 1
|
|
21726
|
+
} : row)
|
|
21727
|
+
};
|
|
21728
|
+
next = this.putOwnerGeneration(next, key, this.ownerGeneration(key) + 1);
|
|
21729
|
+
next = {
|
|
21730
|
+
...next,
|
|
21731
|
+
pending: next.pending.filter((intent) => idOfKey(intent.key) !== idOfKey(key))
|
|
21732
|
+
};
|
|
21733
|
+
await this.commit(next);
|
|
21734
|
+
});
|
|
21735
|
+
}
|
|
21736
|
+
async settleRecipient(key, expectedGeneration, result) {
|
|
21737
|
+
await this.serialize(async () => {
|
|
21738
|
+
this.assertReady();
|
|
21739
|
+
const registration = this.state.registrations.find((row) => idOfKey(row) === idOfKey(key));
|
|
21740
|
+
if (!registration || registration.generation !== expectedGeneration) return;
|
|
21741
|
+
if (result === "gone") {
|
|
21742
|
+
const next = {
|
|
21743
|
+
...this.advanceOwnerGenerationFloor(this.state, [registration.generation]),
|
|
21744
|
+
registrations: this.state.registrations.filter((row) => idOfKey(row) !== idOfKey(key)),
|
|
21745
|
+
pending: this.state.pending.filter((row) => idOfKey(row.key) !== idOfKey(key)),
|
|
21746
|
+
receipts: this.state.receipts.filter((row) => idOfKey(row.key) !== idOfKey(key)),
|
|
21747
|
+
references: this.state.references.filter((row) => idOfKey(row.key) !== idOfKey(key))
|
|
21748
|
+
};
|
|
21749
|
+
await this.commit(next);
|
|
21750
|
+
} else {
|
|
21751
|
+
const updated = {
|
|
21752
|
+
...registration,
|
|
21753
|
+
health: "suspended",
|
|
21754
|
+
generation: registration.generation + 1
|
|
21755
|
+
};
|
|
21756
|
+
await this.commit({
|
|
21757
|
+
...this.state,
|
|
21758
|
+
registrations: this.state.registrations.map((row) => idOfKey(row) === idOfKey(key) ? updated : row)
|
|
21759
|
+
});
|
|
21760
|
+
}
|
|
21761
|
+
});
|
|
21762
|
+
}
|
|
21763
|
+
async disableOwner(owner) {
|
|
21764
|
+
await this.serialize(async () => {
|
|
21765
|
+
this.assertReady();
|
|
21766
|
+
let next = this.state;
|
|
21767
|
+
next = {
|
|
21768
|
+
...next,
|
|
21769
|
+
registrations: next.registrations.map((reg) => sameOwner(reg, owner) ? {
|
|
21770
|
+
...reg,
|
|
21771
|
+
enabled: false,
|
|
21772
|
+
generation: reg.generation + 1
|
|
21773
|
+
} : reg),
|
|
21774
|
+
pending: next.pending.filter((intent) => !sameOwner(intent.key, owner))
|
|
21775
|
+
};
|
|
21776
|
+
next = this.putOwnerGeneration(next, owner, this.ownerGeneration(owner) + 1);
|
|
21777
|
+
await this.commit(next);
|
|
21778
|
+
});
|
|
21779
|
+
}
|
|
21780
|
+
async purgeDevices(scopeId, ids) {
|
|
21781
|
+
await this.serialize(async () => {
|
|
21782
|
+
this.assertReady();
|
|
21783
|
+
if (ids.length === 0) return;
|
|
21784
|
+
const selected = new Set(ids);
|
|
21785
|
+
const remove = (key) => key.scopeId === scopeId && selected.has(key.deviceId);
|
|
21786
|
+
const removedRegistrationGenerations = this.state.registrations.filter((reg) => remove(reg)).map((reg) => reg.generation);
|
|
21787
|
+
const withFloor = this.advanceOwnerGenerationFloor(this.state, removedRegistrationGenerations);
|
|
21788
|
+
const next = {
|
|
21789
|
+
...withFloor,
|
|
21790
|
+
ownerGenerations: withFloor.ownerGenerations.filter((row) => !remove(row)),
|
|
21791
|
+
registrations: withFloor.registrations.filter((reg) => !remove(reg)),
|
|
21792
|
+
pending: withFloor.pending.filter((intent) => !remove(intent.key)),
|
|
21793
|
+
receipts: withFloor.receipts.filter((row) => !remove(row.key)),
|
|
21794
|
+
references: withFloor.references.filter((row) => !remove(row.key))
|
|
21795
|
+
};
|
|
21796
|
+
await this.commit(next);
|
|
21797
|
+
});
|
|
21798
|
+
}
|
|
21799
|
+
async resetKeys() {
|
|
21800
|
+
await this.serialize(async () => {
|
|
21801
|
+
this.assertReady();
|
|
21802
|
+
const withFloor = this.advanceOwnerGenerationFloor(this.state, this.state.registrations.map((reg) => reg.generation));
|
|
21803
|
+
const keys = this.createKeys();
|
|
21804
|
+
const nextKeys = {
|
|
21805
|
+
...keys,
|
|
21806
|
+
fingerprint: fingerprint(keys)
|
|
21807
|
+
};
|
|
21808
|
+
const next = {
|
|
21809
|
+
...withFloor,
|
|
21810
|
+
keys: nextKeys,
|
|
21811
|
+
ownerGenerations: [],
|
|
21812
|
+
registrations: [],
|
|
21813
|
+
pending: [],
|
|
21814
|
+
receipts: [],
|
|
21815
|
+
references: []
|
|
21816
|
+
};
|
|
21817
|
+
await this.commit(next);
|
|
21818
|
+
});
|
|
21819
|
+
}
|
|
21820
|
+
async close() {
|
|
21821
|
+
await this.queue;
|
|
21822
|
+
this.closed = true;
|
|
21823
|
+
}
|
|
21824
|
+
pruneExpired(snapshot) {
|
|
21825
|
+
const now = Date.now();
|
|
21826
|
+
const pending = snapshot.pending.filter((item) => item.source.expiresAt > now);
|
|
21827
|
+
return {
|
|
21828
|
+
...snapshot,
|
|
21829
|
+
pending,
|
|
21830
|
+
receipts: snapshot.receipts.filter((item) => item.expiresAt > now),
|
|
21831
|
+
references: snapshot.references.filter((item) => item.expiresAt > now)
|
|
21832
|
+
};
|
|
21833
|
+
}
|
|
21834
|
+
};
|
|
21835
|
+
//#endregion
|
|
21836
|
+
//#region src/mobile-push/payload.ts
|
|
21837
|
+
const messages = {
|
|
21838
|
+
en: {
|
|
21839
|
+
question: ["Input requested", "A response needs your attention."],
|
|
21840
|
+
plan: ["Plan review requested", "A plan review needs your attention."],
|
|
21841
|
+
approval: ["Permission requested", "A permission request needs your attention."],
|
|
21842
|
+
response: ["Response ready", "A response is ready to view."],
|
|
21843
|
+
goal: ["Goal update", "A goal has an important update."],
|
|
21844
|
+
failure: ["Attention needed", "A run needs your attention."],
|
|
21845
|
+
test: ["Test notification", "Your notification settings are working."]
|
|
21846
|
+
},
|
|
21847
|
+
"zh-CN": {
|
|
21848
|
+
question: ["需要输入", "有一项回复需要您关注。"],
|
|
21849
|
+
plan: ["需要审核计划", "有一项计划审核需要您关注。"],
|
|
21850
|
+
approval: ["需要授权", "有一项授权请求需要您关注。"],
|
|
21851
|
+
response: ["回复已就绪", "有一项回复已准备好查看。"],
|
|
21852
|
+
goal: ["目标更新", "有一项目标状态更新。"],
|
|
21853
|
+
failure: ["需要关注", "有一项运行需要您关注。"],
|
|
21854
|
+
test: ["测试通知", "通知设置已正常工作。"]
|
|
21855
|
+
},
|
|
21856
|
+
it: {
|
|
21857
|
+
question: ["Input richiesto", "Una risposta richiede la tua attenzione."],
|
|
21858
|
+
plan: ["Revisione del piano richiesta", "Una revisione del piano richiede la tua attenzione."],
|
|
21859
|
+
approval: ["Autorizzazione richiesta", "Una richiesta di autorizzazione richiede la tua attenzione."],
|
|
21860
|
+
response: ["Risposta pronta", "Una risposta è pronta da visualizzare."],
|
|
21861
|
+
goal: ["Aggiornamento obiettivo", "Un obiettivo ha un aggiornamento importante."],
|
|
21862
|
+
failure: ["Attenzione richiesta", "Un’esecuzione richiede la tua attenzione."],
|
|
21863
|
+
test: ["Notifica di prova", "Le impostazioni delle notifiche funzionano."]
|
|
21864
|
+
}
|
|
21865
|
+
};
|
|
21866
|
+
function validateNavigate(navigate, category) {
|
|
21867
|
+
if (category === "test" && navigate === "/") return;
|
|
21868
|
+
if (typeof navigate !== "string" || !/^\/\?mobile-push=[A-Za-z0-9_-]{1,256}$/u.test(navigate)) throw new TypeError("push_navigation_invalid");
|
|
21869
|
+
}
|
|
21870
|
+
/** Build Apple's declarative Web Push schema with generic, locale-owned text. */
|
|
21871
|
+
function buildDeclarativePayload(category, locale, navigate, tag) {
|
|
21872
|
+
validateNavigate(navigate, category);
|
|
21873
|
+
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");
|
|
21874
|
+
const [title, body] = messages[locale][category];
|
|
21875
|
+
const payload = JSON.stringify({
|
|
21876
|
+
web_push: 8030,
|
|
21877
|
+
notification: {
|
|
21878
|
+
title,
|
|
21879
|
+
body,
|
|
21880
|
+
lang: locale,
|
|
21881
|
+
navigate,
|
|
21882
|
+
tag
|
|
21883
|
+
}
|
|
21884
|
+
});
|
|
21885
|
+
if (Buffer.byteLength(payload, "utf8") > 3072) throw new TypeError("push_payload_too_large");
|
|
21886
|
+
return payload;
|
|
21887
|
+
}
|
|
21888
|
+
//#endregion
|
|
21889
|
+
//#region src/mobile-push/retention.ts
|
|
21890
|
+
/** Pure defer-membership retention helpers shared by delivery maintenance. */
|
|
21891
|
+
/** Removes deferred-recheck entries whose owning pending intent no longer exists; returns how many were removed. */
|
|
21892
|
+
function pruneOrphanedDeferrals(deferred, pending) {
|
|
21893
|
+
const live = new Set(pending.map((item) => item.id));
|
|
21894
|
+
let removed = 0;
|
|
21895
|
+
for (const id of deferred.keys()) {
|
|
21896
|
+
if (live.has(id)) continue;
|
|
21897
|
+
deferred.delete(id);
|
|
21898
|
+
removed += 1;
|
|
21899
|
+
}
|
|
21900
|
+
return removed;
|
|
21901
|
+
}
|
|
21902
|
+
//#endregion
|
|
21903
|
+
//#region src/mobile-push/sender.ts
|
|
21904
|
+
const MAX_RESPONSE_BYTES = 8192;
|
|
21905
|
+
const MAX_TTL_SECONDS = 1800;
|
|
21906
|
+
const CONTENT_ENCODING = "aes128gcm";
|
|
21907
|
+
function isObject(value) {
|
|
21908
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21909
|
+
}
|
|
21910
|
+
function validatePayload(payload) {
|
|
21911
|
+
if (typeof payload !== "string" || Buffer.byteLength(payload, "utf8") > 3072) return false;
|
|
21912
|
+
try {
|
|
21913
|
+
const parsed = JSON.parse(payload);
|
|
21914
|
+
if (!isObject(parsed) || parsed.web_push !== 8030 || !isObject(parsed.notification) || Object.keys(parsed).length !== 2 || Object.keys(parsed.notification).length !== 5) return false;
|
|
21915
|
+
const notification = parsed.notification;
|
|
21916
|
+
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);
|
|
21917
|
+
} catch {
|
|
21918
|
+
return false;
|
|
21919
|
+
}
|
|
21920
|
+
}
|
|
21921
|
+
function validSubject(subject, origin) {
|
|
21922
|
+
if (typeof subject !== "string") return void 0;
|
|
21923
|
+
const candidate = subject.trim() === "" ? `${origin}/` : subject;
|
|
21924
|
+
let url;
|
|
21925
|
+
try {
|
|
21926
|
+
url = new URL(candidate);
|
|
21927
|
+
} catch {
|
|
21928
|
+
return;
|
|
21929
|
+
}
|
|
21930
|
+
if (url.protocol === "mailto:") return /^mailto:[^\s/?#@]+@[^\s/?#@]+$/iu.test(candidate) ? candidate : void 0;
|
|
21931
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") return void 0;
|
|
21932
|
+
const hostname = url.hostname.toLowerCase();
|
|
21933
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackAddress(hostname)) return void 0;
|
|
21934
|
+
return candidate;
|
|
21935
|
+
}
|
|
21936
|
+
function canonicalOrigin(origin) {
|
|
21937
|
+
try {
|
|
21938
|
+
const url = new URL(origin);
|
|
21939
|
+
if (url.protocol !== "https:" || url.origin !== origin || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") return void 0;
|
|
21940
|
+
return url.origin;
|
|
21941
|
+
} catch {
|
|
21942
|
+
return;
|
|
21943
|
+
}
|
|
21944
|
+
}
|
|
21945
|
+
/**
|
|
21946
|
+
* WebKit's declarative push parser resolves `navigate` without any base URL, so the encrypted wire
|
|
21947
|
+
* payload must carry an absolute URL. The already-validated relative template is resolved against
|
|
21948
|
+
* the canonical registration origin and the serialized copy is budget-checked again because the
|
|
21949
|
+
* origin adds bytes the payload template never counted.
|
|
21950
|
+
*/
|
|
21951
|
+
function bindNavigationOrigin(payload, origin) {
|
|
21952
|
+
let parsed;
|
|
21953
|
+
try {
|
|
21954
|
+
parsed = JSON.parse(payload);
|
|
21955
|
+
} catch {
|
|
21956
|
+
return;
|
|
21957
|
+
}
|
|
21958
|
+
if (typeof parsed?.notification?.navigate !== "string") return void 0;
|
|
21959
|
+
let resolved;
|
|
21960
|
+
try {
|
|
21961
|
+
resolved = new URL(parsed.notification.navigate, origin);
|
|
21962
|
+
} catch {
|
|
21963
|
+
return;
|
|
21964
|
+
}
|
|
21965
|
+
if (resolved.origin !== origin) return void 0;
|
|
21966
|
+
const bound = JSON.stringify({
|
|
21967
|
+
...parsed,
|
|
21968
|
+
notification: {
|
|
21969
|
+
...parsed.notification,
|
|
21970
|
+
navigate: resolved.href
|
|
21971
|
+
}
|
|
21972
|
+
});
|
|
21973
|
+
if (Buffer.byteLength(bound, "utf8") > 3072) return void 0;
|
|
21974
|
+
return bound;
|
|
21975
|
+
}
|
|
21976
|
+
function retryAfterMs(value, now) {
|
|
21977
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
21978
|
+
if (!raw) return void 0;
|
|
21979
|
+
if (/^\d{1,10}$/u.test(raw)) return Number(raw) * 1e3;
|
|
21980
|
+
const date = Date.parse(raw);
|
|
21981
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : void 0;
|
|
21982
|
+
}
|
|
21983
|
+
function safeStatus(statusCode, retryAfter, ttlSeconds) {
|
|
21984
|
+
if (statusCode >= 200 && statusCode < 300) return { kind: "accepted" };
|
|
21985
|
+
if (statusCode === 404 || statusCode === 410) return { kind: "gone" };
|
|
21986
|
+
if (statusCode === 401 || statusCode === 403) return { kind: "suspended" };
|
|
21987
|
+
if (statusCode === 429) {
|
|
21988
|
+
if (retryAfter !== void 0 && retryAfter > ttlSeconds * 1e3) return { kind: "permanent" };
|
|
21989
|
+
return retryAfter === void 0 ? { kind: "retry" } : {
|
|
21990
|
+
kind: "retry",
|
|
21991
|
+
retryAfterMs: retryAfter
|
|
21992
|
+
};
|
|
21993
|
+
}
|
|
21994
|
+
if (statusCode >= 500 && statusCode < 600) return { kind: "retry" };
|
|
21995
|
+
return { kind: "permanent" };
|
|
21996
|
+
}
|
|
21997
|
+
function abortable(promise, signal) {
|
|
21998
|
+
if (signal.aborted) return Promise.reject(/* @__PURE__ */ new Error("push_aborted"));
|
|
21999
|
+
return new Promise((resolve, reject) => {
|
|
22000
|
+
const abort = () => reject(/* @__PURE__ */ new Error("push_aborted"));
|
|
22001
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
22002
|
+
promise.then((value) => {
|
|
22003
|
+
signal.removeEventListener("abort", abort);
|
|
22004
|
+
resolve(value);
|
|
22005
|
+
}, (error) => {
|
|
22006
|
+
signal.removeEventListener("abort", abort);
|
|
22007
|
+
reject(error);
|
|
22008
|
+
});
|
|
22009
|
+
});
|
|
22010
|
+
}
|
|
22011
|
+
function responseFromRequest(requestFactory, url, options, body, signal) {
|
|
22012
|
+
return new Promise((resolve, reject) => {
|
|
22013
|
+
let request;
|
|
22014
|
+
let settled = false;
|
|
22015
|
+
const finish = (result, error) => {
|
|
22016
|
+
if (settled) return;
|
|
22017
|
+
settled = true;
|
|
22018
|
+
signal.removeEventListener("abort", abortRequest);
|
|
22019
|
+
if (error) reject(error);
|
|
22020
|
+
else if (result) resolve(result);
|
|
22021
|
+
};
|
|
22022
|
+
const abortRequest = () => {
|
|
22023
|
+
request?.destroy(/* @__PURE__ */ new Error("push_aborted"));
|
|
22024
|
+
finish(void 0, /* @__PURE__ */ new Error("push_aborted"));
|
|
22025
|
+
};
|
|
22026
|
+
try {
|
|
22027
|
+
request = requestFactory(url, options);
|
|
22028
|
+
signal.addEventListener("abort", abortRequest, { once: true });
|
|
22029
|
+
request.once("error", () => finish(void 0, /* @__PURE__ */ new Error("push_transport_failed")));
|
|
22030
|
+
request.once("response", (response) => {
|
|
22031
|
+
const statusCode = response.statusCode;
|
|
22032
|
+
if (typeof statusCode !== "number") {
|
|
22033
|
+
response.destroy();
|
|
22034
|
+
request.destroy();
|
|
22035
|
+
finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22036
|
+
return;
|
|
22037
|
+
}
|
|
22038
|
+
if (statusCode >= 300 && statusCode < 400) {
|
|
22039
|
+
finish({
|
|
22040
|
+
statusCode,
|
|
22041
|
+
headers: response.headers
|
|
22042
|
+
});
|
|
22043
|
+
response.destroy();
|
|
22044
|
+
request.destroy();
|
|
22045
|
+
return;
|
|
22046
|
+
}
|
|
22047
|
+
let bytes = 0;
|
|
22048
|
+
let oversized = false;
|
|
22049
|
+
response.on("data", (chunk) => {
|
|
22050
|
+
bytes += Buffer.byteLength(chunk);
|
|
22051
|
+
if (bytes > MAX_RESPONSE_BYTES && !oversized) {
|
|
22052
|
+
oversized = true;
|
|
22053
|
+
finish({
|
|
22054
|
+
statusCode,
|
|
22055
|
+
headers: response.headers
|
|
22056
|
+
});
|
|
22057
|
+
response.destroy();
|
|
22058
|
+
request.destroy();
|
|
22059
|
+
}
|
|
22060
|
+
});
|
|
22061
|
+
response.once("error", () => {
|
|
22062
|
+
if (!oversized) finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22063
|
+
});
|
|
22064
|
+
response.once("end", () => finish({
|
|
22065
|
+
statusCode,
|
|
22066
|
+
headers: response.headers
|
|
22067
|
+
}));
|
|
22068
|
+
});
|
|
22069
|
+
request.end(body);
|
|
22070
|
+
} catch {
|
|
22071
|
+
finish(void 0, /* @__PURE__ */ new Error("push_transport_failed"));
|
|
22072
|
+
}
|
|
22073
|
+
});
|
|
22074
|
+
}
|
|
22075
|
+
function makePinnedLookup(hostname, address) {
|
|
22076
|
+
return ((queryHostname, options, callback) => {
|
|
22077
|
+
if (queryHostname.toLowerCase() !== hostname.toLowerCase()) {
|
|
22078
|
+
callback(/* @__PURE__ */ new Error("push_dns_pin_mismatch"));
|
|
22079
|
+
return;
|
|
22080
|
+
}
|
|
22081
|
+
if (isObject(options) && options.all === true) callback(null, [{
|
|
22082
|
+
address: address.address,
|
|
22083
|
+
family: address.family
|
|
22084
|
+
}]);
|
|
22085
|
+
else callback(null, address.address, address.family);
|
|
22086
|
+
});
|
|
22087
|
+
}
|
|
22088
|
+
function requestOptions(url, details, address) {
|
|
22089
|
+
return {
|
|
22090
|
+
method: details.method,
|
|
22091
|
+
headers: details.headers,
|
|
22092
|
+
agent: false,
|
|
22093
|
+
servername: url.hostname,
|
|
22094
|
+
rejectUnauthorized: true,
|
|
22095
|
+
lookup: makePinnedLookup(url.hostname, address),
|
|
22096
|
+
timeout: ATTEMPT_TIMEOUT_MS
|
|
22097
|
+
};
|
|
22098
|
+
}
|
|
22099
|
+
/** Generate one persistent installation VAPID key pair via the maintained library. */
|
|
22100
|
+
function createVapidKeys() {
|
|
22101
|
+
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
|
22102
|
+
return Object.freeze({
|
|
22103
|
+
publicKey,
|
|
22104
|
+
privateKey
|
|
22105
|
+
});
|
|
22106
|
+
}
|
|
22107
|
+
/** Encrypted, no-redirect HTTPS sender with DNS pinning and one absolute wall-clock budget. */
|
|
22108
|
+
var PushSender = class {
|
|
22109
|
+
resolver;
|
|
22110
|
+
requestFactory;
|
|
22111
|
+
budgetMs;
|
|
22112
|
+
constructor(dependencies = {}) {
|
|
22113
|
+
this.resolver = dependencies.resolve ?? (async (hostname) => {
|
|
22114
|
+
return (await lookup(hostname, {
|
|
22115
|
+
all: true,
|
|
22116
|
+
verbatim: true
|
|
22117
|
+
})).filter((item) => item.family === 4 || item.family === 6);
|
|
22118
|
+
});
|
|
22119
|
+
this.requestFactory = dependencies.request ?? https.request.bind(https);
|
|
22120
|
+
this.budgetMs = Math.max(1, Math.min(ATTEMPT_TIMEOUT_MS, dependencies.attemptTimeoutMs ?? 1e4));
|
|
22121
|
+
}
|
|
22122
|
+
async send(input) {
|
|
22123
|
+
const started = performance.now();
|
|
22124
|
+
const controller = new AbortController();
|
|
22125
|
+
const callerAbort = () => controller.abort();
|
|
22126
|
+
if (input.signal.aborted) controller.abort();
|
|
22127
|
+
else input.signal.addEventListener("abort", callerAbort, { once: true });
|
|
22128
|
+
const deadline = setTimeout(() => controller.abort(), this.budgetMs);
|
|
22129
|
+
try {
|
|
22130
|
+
if (controller.signal.aborted) return { kind: "retry" };
|
|
22131
|
+
if (!Number.isSafeInteger(input.ttlSeconds) || input.ttlSeconds < 1 || input.ttlSeconds > MAX_TTL_SECONDS || !validatePayload(input.payload)) return { kind: "permanent" };
|
|
22132
|
+
const origin = canonicalOrigin(input.registration.origin);
|
|
22133
|
+
if (!origin) return { kind: "permanent" };
|
|
22134
|
+
const wirePayload = bindNavigationOrigin(input.payload, origin);
|
|
22135
|
+
if (!wirePayload) return { kind: "permanent" };
|
|
22136
|
+
const subject = validSubject(input.subject, origin);
|
|
22137
|
+
if (!subject) return { kind: "permanent" };
|
|
22138
|
+
let subscription;
|
|
22139
|
+
try {
|
|
22140
|
+
subscription = validatePushSubscription(input.registration.subscription);
|
|
22141
|
+
} catch {
|
|
22142
|
+
return { kind: "permanent" };
|
|
22143
|
+
}
|
|
22144
|
+
const resolved = await abortable(resolveAppleEndpoint(subscription.endpoint, this.resolver), controller.signal);
|
|
22145
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22146
|
+
const options = {
|
|
22147
|
+
contentEncoding: CONTENT_ENCODING,
|
|
22148
|
+
vapidDetails: {
|
|
22149
|
+
subject,
|
|
22150
|
+
publicKey: input.keys.publicKey,
|
|
22151
|
+
privateKey: input.keys.privateKey
|
|
22152
|
+
},
|
|
22153
|
+
TTL: input.ttlSeconds,
|
|
22154
|
+
timeout: ATTEMPT_TIMEOUT_MS
|
|
22155
|
+
};
|
|
22156
|
+
let details;
|
|
22157
|
+
try {
|
|
22158
|
+
details = webPush.generateRequestDetails(subscription, wirePayload, options);
|
|
22159
|
+
} catch {
|
|
22160
|
+
return { kind: "permanent" };
|
|
22161
|
+
}
|
|
22162
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22163
|
+
const requestConfig = requestOptions(resolved.url, details, resolved.addresses[0]);
|
|
22164
|
+
const beforeRequest = input.beforeRequest;
|
|
22165
|
+
if (beforeRequest !== void 0) {
|
|
22166
|
+
if (typeof beforeRequest !== "function") return { kind: "cancelled" };
|
|
22167
|
+
let permitted;
|
|
22168
|
+
try {
|
|
22169
|
+
permitted = await abortable(Promise.resolve().then(() => beforeRequest(controller.signal)), controller.signal);
|
|
22170
|
+
} catch {
|
|
22171
|
+
return { kind: "cancelled" };
|
|
22172
|
+
}
|
|
22173
|
+
if (permitted !== true || controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "cancelled" };
|
|
22174
|
+
}
|
|
22175
|
+
const response = await abortable(responseFromRequest(this.requestFactory, resolved.url, requestConfig, details.body ?? Buffer.alloc(0), controller.signal), controller.signal);
|
|
22176
|
+
if (controller.signal.aborted || performance.now() - started > this.budgetMs) return { kind: "retry" };
|
|
22177
|
+
const waitMs = retryAfterMs(response.headers["retry-after"], Date.now());
|
|
22178
|
+
return safeStatus(response.statusCode, waitMs, input.ttlSeconds);
|
|
22179
|
+
} catch (error) {
|
|
22180
|
+
if (error instanceof TypeError && error.message === "push_endpoint_address_rejected") return { kind: "permanent" };
|
|
22181
|
+
if (controller.signal.aborted || performance.now() - started >= this.budgetMs) return { kind: "retry" };
|
|
22182
|
+
return { kind: "retry" };
|
|
22183
|
+
} finally {
|
|
22184
|
+
clearTimeout(deadline);
|
|
22185
|
+
input.signal.removeEventListener("abort", callerAbort);
|
|
22186
|
+
}
|
|
22187
|
+
}
|
|
22188
|
+
};
|
|
22189
|
+
//#endregion
|
|
22190
|
+
//#region src/mobile-push/delivery.ts
|
|
22191
|
+
const RECONCILE_INTERVAL_MS = 3e4;
|
|
22192
|
+
const RETRY_BACKOFF_MS = [1e3, 5e3];
|
|
22193
|
+
const MAX_TEST_QUEUE = 64;
|
|
22194
|
+
const TEST_RATE_LIMIT_MS = 6e4;
|
|
22195
|
+
const UNVERIFIABLE_RECHECK_MS = 3e4;
|
|
22196
|
+
function ownerId(value) {
|
|
22197
|
+
return JSON.stringify([
|
|
22198
|
+
value.scopeId,
|
|
22199
|
+
value.deviceId,
|
|
22200
|
+
value.origin
|
|
22201
|
+
]);
|
|
22202
|
+
}
|
|
22203
|
+
function keyId(value) {
|
|
22204
|
+
return JSON.stringify([
|
|
22205
|
+
value.scopeId,
|
|
22206
|
+
value.deviceId,
|
|
22207
|
+
value.origin,
|
|
22208
|
+
value.installationId
|
|
22209
|
+
]);
|
|
22210
|
+
}
|
|
22211
|
+
function sameKey(a, b) {
|
|
22212
|
+
return keyId(a) === keyId(b);
|
|
22213
|
+
}
|
|
22214
|
+
function isPreferenceEnabled(preferences, category) {
|
|
22215
|
+
return preferences[category] === true;
|
|
22216
|
+
}
|
|
22217
|
+
function pendingHas(snapshot, intent) {
|
|
22218
|
+
return snapshot.pending.some((item) => item.id === intent.id && sameKey(item.key, intent.key));
|
|
22219
|
+
}
|
|
22220
|
+
function recipientCurrent(snapshot, intent) {
|
|
22221
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, intent.key));
|
|
22222
|
+
return !!registration && registration.enabled && registration.health === "ready" && registration.generation === intent.recipientGeneration && registration.keyFingerprint === snapshot.keys?.fingerprint && isPreferenceEnabled(registration.preferences, intent.source.category);
|
|
22223
|
+
}
|
|
22224
|
+
function boundedSnapshot(snapshot, now) {
|
|
22225
|
+
return {
|
|
22226
|
+
...snapshot,
|
|
22227
|
+
pending: snapshot.pending.filter((item) => item.source.expiresAt > now),
|
|
22228
|
+
receipts: snapshot.receipts.filter((item) => item.expiresAt > now),
|
|
22229
|
+
references: snapshot.references.filter((item) => item.expiresAt > now)
|
|
22230
|
+
};
|
|
22231
|
+
}
|
|
22232
|
+
/** Coordinates bounded, consent-checked delivery while all durable writes remain registry-owned. */
|
|
22233
|
+
var PushDelivery = class {
|
|
22234
|
+
#host;
|
|
22235
|
+
#registry;
|
|
22236
|
+
#sender;
|
|
22237
|
+
#now;
|
|
22238
|
+
#origins = /* @__PURE__ */ new Map();
|
|
22239
|
+
#inFlight = /* @__PURE__ */ new Map();
|
|
22240
|
+
#inFlightIds = /* @__PURE__ */ new Set();
|
|
22241
|
+
#tasks = /* @__PURE__ */ new Set();
|
|
22242
|
+
#tests = [];
|
|
22243
|
+
#testAttempts = /* @__PURE__ */ new Map();
|
|
22244
|
+
#deferredUntil = /* @__PURE__ */ new Map();
|
|
22245
|
+
#purgeTasks = /* @__PURE__ */ new Map();
|
|
22246
|
+
#writeQueue = Promise.resolve();
|
|
22247
|
+
#unsubscribe;
|
|
22248
|
+
#interval;
|
|
22249
|
+
#wakeTimer;
|
|
22250
|
+
#started = false;
|
|
22251
|
+
#closed = false;
|
|
22252
|
+
#admitting = true;
|
|
22253
|
+
#pumping = false;
|
|
22254
|
+
#active = 0;
|
|
22255
|
+
constructor(options) {
|
|
22256
|
+
this.#host = options.host;
|
|
22257
|
+
this.#registry = options.registry;
|
|
22258
|
+
this.#sender = options.sender;
|
|
22259
|
+
this.#now = options.now ?? Date.now;
|
|
22260
|
+
}
|
|
22261
|
+
async start() {
|
|
22262
|
+
if (this.#closed) throw new Error("push delivery is closed");
|
|
22263
|
+
if (this.#started) return;
|
|
22264
|
+
this.#started = true;
|
|
22265
|
+
this.#unsubscribe = this.#host.subscribe((source) => this.observe(source), (sessionId) => {
|
|
22266
|
+
this.#reconcileSession(sessionId).catch(() => void 0);
|
|
22267
|
+
});
|
|
22268
|
+
await this.#reconcileKnownScopes();
|
|
22269
|
+
await this.#reconcilePending();
|
|
22270
|
+
if (this.#closed) return;
|
|
22271
|
+
this.#interval = setInterval(() => {
|
|
22272
|
+
this.#periodicReconcile().catch(() => void 0);
|
|
22273
|
+
}, RECONCILE_INTERVAL_MS);
|
|
22274
|
+
this.#kick();
|
|
22275
|
+
}
|
|
22276
|
+
/** Nonblocking host-observer boundary: admission and durable outbox writes happen later. */
|
|
22277
|
+
observe(source) {
|
|
22278
|
+
if (!this.#admitting || !this.#started || this.#closed) return;
|
|
22279
|
+
this.#admit(source).catch(() => {});
|
|
22280
|
+
}
|
|
22281
|
+
attachOrigin(input) {
|
|
22282
|
+
if (this.#closed) return () => void 0;
|
|
22283
|
+
const id = this.#originId(input);
|
|
22284
|
+
const previous = this.#origins.get(id);
|
|
22285
|
+
if (previous?.active) {
|
|
22286
|
+
previous.active = false;
|
|
22287
|
+
previous.generation += 1;
|
|
22288
|
+
for (const work of this.#inFlight.values()) if (work.origin === previous) work.controller.abort();
|
|
22289
|
+
}
|
|
22290
|
+
const origin = {
|
|
22291
|
+
...input,
|
|
22292
|
+
active: true,
|
|
22293
|
+
generation: (previous?.generation ?? 0) + 1
|
|
22294
|
+
};
|
|
22295
|
+
this.#origins.set(id, origin);
|
|
22296
|
+
this.#clearOriginDeferrals(origin);
|
|
22297
|
+
this.#kick();
|
|
22298
|
+
return () => {
|
|
22299
|
+
if (!origin.active || this.#origins.get(id) !== origin) return;
|
|
22300
|
+
origin.active = false;
|
|
22301
|
+
origin.generation += 1;
|
|
22302
|
+
for (const work of this.#inFlight.values()) if (work.origin === origin) work.controller.abort();
|
|
22303
|
+
};
|
|
22304
|
+
}
|
|
22305
|
+
async invalidateDevices(scopeId, ids) {
|
|
22306
|
+
if (ids.length === 0 || this.#closed) return;
|
|
22307
|
+
const selected = new Set(ids);
|
|
22308
|
+
for (const work of this.#inFlight.values()) if (work.key.scopeId === scopeId && selected.has(work.key.deviceId)) work.controller.abort();
|
|
22309
|
+
await this.#purgeDevices(scopeId, ids);
|
|
22310
|
+
this.#kick();
|
|
22311
|
+
}
|
|
22312
|
+
async test(key) {
|
|
22313
|
+
if (!this.#started || this.#closed || this.#tests.length >= MAX_TEST_QUEUE) throw new Error("push_test_unavailable");
|
|
22314
|
+
const identity = keyId(key);
|
|
22315
|
+
const now = this.#now();
|
|
22316
|
+
const last = this.#testAttempts.get(identity);
|
|
22317
|
+
if (last !== void 0 && now - last < TEST_RATE_LIMIT_MS) throw new Error("push_test_rate_limited");
|
|
22318
|
+
this.#testAttempts.set(identity, now);
|
|
22319
|
+
while (this.#testAttempts.size > 512) this.#testAttempts.delete(this.#testAttempts.keys().next().value);
|
|
22320
|
+
return new Promise((resolve, reject) => {
|
|
22321
|
+
this.#tests.push({
|
|
22322
|
+
kind: "test",
|
|
22323
|
+
key: { ...key },
|
|
22324
|
+
resolve,
|
|
22325
|
+
reject
|
|
22326
|
+
});
|
|
22327
|
+
this.#kick();
|
|
22328
|
+
});
|
|
22329
|
+
}
|
|
22330
|
+
async resolve(owner, reference) {
|
|
22331
|
+
if (this.#closed || !reference) return void 0;
|
|
22332
|
+
let snapshot;
|
|
22333
|
+
try {
|
|
22334
|
+
snapshot = this.#registry.snapshot();
|
|
22335
|
+
} catch {
|
|
22336
|
+
return;
|
|
22337
|
+
}
|
|
22338
|
+
const row = snapshot.references.find((item) => item.reference === reference && ownerId(item.key) === ownerId(owner) && item.expiresAt > this.#now());
|
|
22339
|
+
if (!row || row.source.expiresAt <= this.#now()) return void 0;
|
|
22340
|
+
const origin = this.#originFor(row.key);
|
|
22341
|
+
if (!origin?.active || await this.#guardKey(row.key, row.source, origin, true) !== "allowed") return void 0;
|
|
22342
|
+
try {
|
|
22343
|
+
if (!await this.#host.sessionExists(row.source.targetSessionId)) return void 0;
|
|
22344
|
+
return await this.#host.resolveTarget(row.source);
|
|
22345
|
+
} catch {
|
|
22346
|
+
return;
|
|
22347
|
+
}
|
|
22348
|
+
}
|
|
22349
|
+
async close() {
|
|
22350
|
+
if (this.#closed) return;
|
|
22351
|
+
this.#admitting = false;
|
|
22352
|
+
this.#closed = true;
|
|
22353
|
+
this.#unsubscribe?.();
|
|
22354
|
+
this.#unsubscribe = void 0;
|
|
22355
|
+
if (this.#interval) clearInterval(this.#interval);
|
|
22356
|
+
if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
|
|
22357
|
+
this.#interval = void 0;
|
|
22358
|
+
this.#wakeTimer = void 0;
|
|
22359
|
+
for (const work of this.#inFlight.values()) work.controller.abort();
|
|
22360
|
+
for (const item of this.#tests.splice(0)) item.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22361
|
+
await Promise.allSettled([...this.#tasks]);
|
|
22362
|
+
await this.#writeQueue;
|
|
22363
|
+
await Promise.allSettled([...new Set(this.#purgeTasks.values())]);
|
|
22364
|
+
this.#deferredUntil.clear();
|
|
22365
|
+
for (const origin of this.#origins.values()) origin.active = false;
|
|
22366
|
+
this.#origins.clear();
|
|
22367
|
+
}
|
|
22368
|
+
async #admit(source) {
|
|
22369
|
+
if (!this.#admitting || source.expiresAt <= this.#now()) return;
|
|
22370
|
+
let eligible = false;
|
|
22371
|
+
try {
|
|
22372
|
+
eligible = this.#host.available && await this.#host.eligible(source);
|
|
22373
|
+
} catch {
|
|
22374
|
+
eligible = false;
|
|
22375
|
+
}
|
|
22376
|
+
if (!eligible || source.expiresAt <= this.#now()) return;
|
|
22377
|
+
for (const origin of this.#origins.values()) {
|
|
22378
|
+
if (!origin.active || this.#closed) continue;
|
|
22379
|
+
const devices = await this.#readAuthorized(origin);
|
|
22380
|
+
if (!devices || !this.#admitting || this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin) continue;
|
|
22381
|
+
let snapshot;
|
|
22382
|
+
try {
|
|
22383
|
+
snapshot = this.#registry.snapshot();
|
|
22384
|
+
} catch {
|
|
22385
|
+
return;
|
|
22386
|
+
}
|
|
22387
|
+
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);
|
|
22388
|
+
if (registrations.length === 0) continue;
|
|
22389
|
+
await this.#write((current) => {
|
|
22390
|
+
const clean = boundedSnapshot(current, this.#now());
|
|
22391
|
+
if (!this.#admitting || this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin) return clean;
|
|
22392
|
+
const pending = [...clean.pending];
|
|
22393
|
+
for (const registration of registrations) {
|
|
22394
|
+
const key = {
|
|
22395
|
+
scopeId: registration.scopeId,
|
|
22396
|
+
deviceId: registration.deviceId,
|
|
22397
|
+
origin: registration.origin,
|
|
22398
|
+
installationId: registration.installationId
|
|
22399
|
+
};
|
|
22400
|
+
const currentRegistration = clean.registrations.find((row) => sameKey(row, key));
|
|
22401
|
+
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;
|
|
22402
|
+
if (clean.receipts.some((item) => item.sourceId === source.sourceId && sameKey(item.key, key))) continue;
|
|
22403
|
+
if (pending.some((item) => item.source.sourceId === source.sourceId && sameKey(item.key, key))) continue;
|
|
22404
|
+
if (pending.filter((item) => item.key.scopeId === origin.scopeId).length >= 256) continue;
|
|
22405
|
+
pending.push({
|
|
22406
|
+
id: randomUUID(),
|
|
22407
|
+
key,
|
|
22408
|
+
source: structuredClone(source),
|
|
22409
|
+
ownerGeneration: current.ownerGenerations.find((row) => ownerId(row) === ownerId(key))?.generation ?? 0,
|
|
22410
|
+
recipientGeneration: registration.generation,
|
|
22411
|
+
attempts: 0,
|
|
22412
|
+
nextAttemptAt: this.#now()
|
|
22413
|
+
});
|
|
22414
|
+
}
|
|
22415
|
+
return {
|
|
22416
|
+
...clean,
|
|
22417
|
+
pending
|
|
22418
|
+
};
|
|
22419
|
+
});
|
|
22420
|
+
}
|
|
22421
|
+
this.#kick();
|
|
22422
|
+
}
|
|
22423
|
+
async #reconcileKnownScopes() {
|
|
22424
|
+
let snapshot;
|
|
22425
|
+
try {
|
|
22426
|
+
snapshot = this.#registry.snapshot();
|
|
22427
|
+
} catch {
|
|
22428
|
+
return;
|
|
22429
|
+
}
|
|
22430
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
22431
|
+
...snapshot.ownerGenerations.map((row) => row.scopeId),
|
|
22432
|
+
...snapshot.registrations.map((row) => row.scopeId),
|
|
22433
|
+
...snapshot.pending.map((row) => row.key.scopeId),
|
|
22434
|
+
...snapshot.receipts.map((row) => row.key.scopeId),
|
|
22435
|
+
...snapshot.references.map((row) => row.key.scopeId),
|
|
22436
|
+
...[...this.#origins.values()].map((row) => row.scopeId)
|
|
22437
|
+
]);
|
|
22438
|
+
for (const scopeId of scopes) {
|
|
22439
|
+
if (this.#closed) return;
|
|
22440
|
+
const scopeOrigins = [...this.#origins.values()].filter((row) => row.scopeId === scopeId);
|
|
22441
|
+
const origin = scopeOrigins.find((row) => row.active) ?? scopeOrigins[0];
|
|
22442
|
+
if (!origin) continue;
|
|
22443
|
+
const authorized = await this.#readAuthorized(origin);
|
|
22444
|
+
if (this.#closed) return;
|
|
22445
|
+
if (!authorized) continue;
|
|
22446
|
+
const current = this.#registry.snapshot();
|
|
22447
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
22448
|
+
for (const row of current.ownerGenerations) if (row.scopeId === scopeId) candidates.add(row.deviceId);
|
|
22449
|
+
for (const row of current.registrations) if (row.scopeId === scopeId) candidates.add(row.deviceId);
|
|
22450
|
+
for (const row of current.pending) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22451
|
+
for (const row of current.receipts) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22452
|
+
for (const row of current.references) if (row.key.scopeId === scopeId) candidates.add(row.key.deviceId);
|
|
22453
|
+
const removed = [...candidates].filter((id) => !authorized.has(id));
|
|
22454
|
+
if (removed.length) await this.#purgeDevices(scopeId, removed);
|
|
22455
|
+
}
|
|
22456
|
+
}
|
|
22457
|
+
async #purgeDevices(scopeId, ids) {
|
|
22458
|
+
const waiting = [];
|
|
22459
|
+
const fresh = [];
|
|
22460
|
+
for (const id of new Set(ids)) {
|
|
22461
|
+
const key = JSON.stringify([scopeId, id]);
|
|
22462
|
+
const ongoing = this.#purgeTasks.get(key);
|
|
22463
|
+
if (ongoing) waiting.push(ongoing);
|
|
22464
|
+
else fresh.push(id);
|
|
22465
|
+
}
|
|
22466
|
+
if (fresh.length) {
|
|
22467
|
+
const task = this.#registry.purgeDevices(scopeId, fresh);
|
|
22468
|
+
for (const id of fresh) this.#purgeTasks.set(JSON.stringify([scopeId, id]), task);
|
|
22469
|
+
const release = () => {
|
|
22470
|
+
for (const id of fresh) if (this.#purgeTasks.get(JSON.stringify([scopeId, id])) === task) this.#purgeTasks.delete(JSON.stringify([scopeId, id]));
|
|
22471
|
+
};
|
|
22472
|
+
task.then(release, release);
|
|
22473
|
+
waiting.push(task);
|
|
22474
|
+
}
|
|
22475
|
+
await Promise.all(waiting);
|
|
22476
|
+
}
|
|
22477
|
+
async #reconcilePending() {
|
|
22478
|
+
let snapshot;
|
|
22479
|
+
try {
|
|
22480
|
+
snapshot = this.#registry.snapshot();
|
|
22481
|
+
} catch {
|
|
22482
|
+
return;
|
|
22483
|
+
}
|
|
22484
|
+
for (const intent of snapshot.pending) {
|
|
22485
|
+
if (this.#closed) return;
|
|
22486
|
+
if (intent.source.expiresAt <= this.#now()) {
|
|
22487
|
+
await this.#removeIntent(intent);
|
|
22488
|
+
continue;
|
|
22489
|
+
}
|
|
22490
|
+
if (snapshot.receipts.some((row) => row.sourceId === intent.source.sourceId && sameKey(row.key, intent.key))) {
|
|
22491
|
+
await this.#removePending(intent);
|
|
22492
|
+
continue;
|
|
22493
|
+
}
|
|
22494
|
+
const guard = await this.#sourceGuard(intent.source);
|
|
22495
|
+
if (this.#closed) return;
|
|
22496
|
+
if (guard === "denied") await this.#removeIntent(intent);
|
|
22497
|
+
else if (guard === "unverifiable") this.#defer(intent);
|
|
22498
|
+
}
|
|
22499
|
+
}
|
|
22500
|
+
async #periodicReconcile() {
|
|
22501
|
+
if (this.#closed) return;
|
|
22502
|
+
this.#pruneDeferred();
|
|
22503
|
+
await this.#reconcileKnownScopes();
|
|
22504
|
+
await this.#reconcilePending();
|
|
22505
|
+
this.#kick();
|
|
22506
|
+
}
|
|
22507
|
+
async #reconcileSession(sessionId) {
|
|
22508
|
+
if (this.#closed) return;
|
|
22509
|
+
let snapshot;
|
|
22510
|
+
try {
|
|
22511
|
+
snapshot = this.#registry.snapshot();
|
|
22512
|
+
} catch {
|
|
22513
|
+
return;
|
|
22514
|
+
}
|
|
22515
|
+
for (const intent of snapshot.pending) {
|
|
22516
|
+
if (intent.source.sessionId !== sessionId) continue;
|
|
22517
|
+
const guard = await this.#sourceGuard(intent.source);
|
|
22518
|
+
if (this.#closed) return;
|
|
22519
|
+
if (guard === "denied") await this.#removeIntent(intent);
|
|
22520
|
+
else if (guard === "unverifiable") this.#defer(intent);
|
|
22521
|
+
}
|
|
22522
|
+
this.#kick();
|
|
22523
|
+
}
|
|
22524
|
+
#kick() {
|
|
22525
|
+
if (!this.#started || this.#closed || this.#pumping) return;
|
|
22526
|
+
this.#pumping = true;
|
|
22527
|
+
this.#pump().finally(() => {
|
|
22528
|
+
this.#pumping = false;
|
|
22529
|
+
this.#scheduleWake();
|
|
22530
|
+
});
|
|
22531
|
+
}
|
|
22532
|
+
async #pump() {
|
|
22533
|
+
while (!this.#closed && this.#active < 4) {
|
|
22534
|
+
const test = this.#tests.shift();
|
|
22535
|
+
if (test) {
|
|
22536
|
+
this.#launchTest(test);
|
|
22537
|
+
continue;
|
|
22538
|
+
}
|
|
22539
|
+
let intents;
|
|
22540
|
+
try {
|
|
22541
|
+
intents = this.#registry.snapshot().pending;
|
|
22542
|
+
} catch {
|
|
22543
|
+
return;
|
|
22544
|
+
}
|
|
22545
|
+
const now = this.#now();
|
|
22546
|
+
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];
|
|
22547
|
+
if (!intent) return;
|
|
22548
|
+
this.#deferredUntil.delete(intent.id);
|
|
22549
|
+
this.#launchIntent(intent);
|
|
22550
|
+
}
|
|
22551
|
+
}
|
|
22552
|
+
#launchIntent(intent) {
|
|
22553
|
+
this.#active += 1;
|
|
22554
|
+
this.#inFlightIds.add(intent.id);
|
|
22555
|
+
const task = this.#sendIntent(intent).catch(() => {
|
|
22556
|
+
this.#defer(intent);
|
|
22557
|
+
}).finally(() => {
|
|
22558
|
+
this.#inFlightIds.delete(intent.id);
|
|
22559
|
+
this.#inFlight.delete(intent.id);
|
|
22560
|
+
this.#active -= 1;
|
|
22561
|
+
this.#kick();
|
|
22562
|
+
});
|
|
22563
|
+
this.#tasks.add(task);
|
|
22564
|
+
task.finally(() => this.#tasks.delete(task));
|
|
22565
|
+
}
|
|
22566
|
+
#launchTest(work) {
|
|
22567
|
+
this.#active += 1;
|
|
22568
|
+
const task = this.#sendTest(work).catch(() => void 0).finally(() => {
|
|
22569
|
+
this.#active -= 1;
|
|
22570
|
+
this.#kick();
|
|
22571
|
+
});
|
|
22572
|
+
this.#tasks.add(task);
|
|
22573
|
+
task.finally(() => this.#tasks.delete(task));
|
|
22574
|
+
}
|
|
22575
|
+
async #sendIntent(intent) {
|
|
22576
|
+
if (this.#closed) return;
|
|
22577
|
+
const origin = this.#originFor(intent.key);
|
|
22578
|
+
if (!origin || this.#originDetached(origin)) {
|
|
22579
|
+
this.#defer(intent);
|
|
22580
|
+
return;
|
|
22581
|
+
}
|
|
22582
|
+
const initial = await this.#guard(intent, origin);
|
|
22583
|
+
if (initial === "denied") {
|
|
22584
|
+
await this.#denyOrDefer(intent, origin);
|
|
22585
|
+
return;
|
|
22586
|
+
}
|
|
22587
|
+
if (initial === "unverifiable") {
|
|
22588
|
+
this.#defer(intent);
|
|
22589
|
+
return;
|
|
22590
|
+
}
|
|
22591
|
+
const setup = await this.#ensureReference(intent);
|
|
22592
|
+
if (!setup) return;
|
|
22593
|
+
const { registration, keys, reference } = setup;
|
|
22594
|
+
const controller = new AbortController();
|
|
22595
|
+
this.#inFlight.set(intent.id, {
|
|
22596
|
+
controller,
|
|
22597
|
+
key: intent.key,
|
|
22598
|
+
origin
|
|
22599
|
+
});
|
|
22600
|
+
if (this.#closed) controller.abort();
|
|
22601
|
+
const remaining = Math.floor((intent.source.expiresAt - this.#now()) / 1e3);
|
|
22602
|
+
if (remaining < 1) {
|
|
22603
|
+
await this.#removeIntent(intent);
|
|
22604
|
+
return;
|
|
22605
|
+
}
|
|
22606
|
+
const payload = buildDeclarativePayload(intent.source.category, registration.locale, `/?mobile-push=${reference}`, intent.source.sourceId.replace(/[^A-Za-z0-9._:-]/gu, "_").slice(0, 128) || "push");
|
|
22607
|
+
let result;
|
|
22608
|
+
try {
|
|
22609
|
+
result = await this.#sender.send({
|
|
22610
|
+
registration,
|
|
22611
|
+
payload,
|
|
22612
|
+
ttlSeconds: Math.min(remaining, 1800),
|
|
22613
|
+
subject: origin.origin,
|
|
22614
|
+
keys,
|
|
22615
|
+
signal: controller.signal,
|
|
22616
|
+
beforeRequest: async (signal) => signal.aborted || controller.signal.aborted ? false : await this.#guard(intent, origin) === "allowed"
|
|
22617
|
+
});
|
|
22618
|
+
} catch {
|
|
22619
|
+
result = { kind: "retry" };
|
|
22620
|
+
}
|
|
22621
|
+
if (this.#closed) return;
|
|
22622
|
+
if (result.kind === "accepted") {
|
|
22623
|
+
await this.#settleIntent(intent, result);
|
|
22624
|
+
return;
|
|
22625
|
+
}
|
|
22626
|
+
if (result.kind === "cancelled") {
|
|
22627
|
+
const current = await this.#guard(intent, origin);
|
|
22628
|
+
if (current === "denied") await this.#denyOrDefer(intent, origin);
|
|
22629
|
+
else if (current === "unverifiable") this.#defer(intent);
|
|
22630
|
+
else await this.#settleIntent(intent, { kind: "retry" });
|
|
22631
|
+
return;
|
|
22632
|
+
}
|
|
22633
|
+
const current = await this.#guard(intent, origin);
|
|
22634
|
+
if (current === "denied") {
|
|
22635
|
+
await this.#denyOrDefer(intent, origin);
|
|
22636
|
+
return;
|
|
22637
|
+
}
|
|
22638
|
+
if (current === "unverifiable") {
|
|
22639
|
+
this.#defer(intent);
|
|
22640
|
+
return;
|
|
22641
|
+
}
|
|
22642
|
+
await this.#settleIntent(intent, result);
|
|
22643
|
+
}
|
|
22644
|
+
async #sendTest(work) {
|
|
22645
|
+
if (this.#closed) {
|
|
22646
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22647
|
+
return;
|
|
22648
|
+
}
|
|
22649
|
+
const origin = this.#originFor(work.key);
|
|
22650
|
+
if (!origin) {
|
|
22651
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22652
|
+
return;
|
|
22653
|
+
}
|
|
22654
|
+
const fakeSource = {
|
|
22655
|
+
sourceId: `test:${randomUUID()}`,
|
|
22656
|
+
sessionId: "",
|
|
22657
|
+
targetSessionId: "",
|
|
22658
|
+
category: "response",
|
|
22659
|
+
createdAt: this.#now(),
|
|
22660
|
+
expiresAt: this.#now() + 6e4,
|
|
22661
|
+
lifetime: "committed"
|
|
22662
|
+
};
|
|
22663
|
+
if (await this.#guardKey(work.key, fakeSource, origin, false) !== "allowed") {
|
|
22664
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22665
|
+
return;
|
|
22666
|
+
}
|
|
22667
|
+
let snapshot;
|
|
22668
|
+
try {
|
|
22669
|
+
snapshot = this.#registry.snapshot();
|
|
22670
|
+
} catch {
|
|
22671
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22672
|
+
return;
|
|
22673
|
+
}
|
|
22674
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, work.key));
|
|
22675
|
+
const keys = snapshot.keys;
|
|
22676
|
+
if (!registration || !keys) {
|
|
22677
|
+
work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22678
|
+
return;
|
|
22679
|
+
}
|
|
22680
|
+
const controller = new AbortController();
|
|
22681
|
+
const workId = `test:${randomUUID()}`;
|
|
22682
|
+
this.#inFlight.set(workId, {
|
|
22683
|
+
controller,
|
|
22684
|
+
key: work.key,
|
|
22685
|
+
origin
|
|
22686
|
+
});
|
|
22687
|
+
let result;
|
|
22688
|
+
try {
|
|
22689
|
+
result = await this.#sender.send({
|
|
22690
|
+
registration,
|
|
22691
|
+
payload: buildDeclarativePayload("test", registration.locale, "/", `test-${randomUUID()}`),
|
|
22692
|
+
ttlSeconds: 60,
|
|
22693
|
+
subject: origin.origin,
|
|
22694
|
+
keys,
|
|
22695
|
+
signal: controller.signal,
|
|
22696
|
+
beforeRequest: async (signal) => !signal.aborted && !controller.signal.aborted && await this.#guardKey(work.key, fakeSource, origin, false, registration.generation) === "allowed"
|
|
22697
|
+
});
|
|
22698
|
+
} catch {
|
|
22699
|
+
result = { kind: "retry" };
|
|
22700
|
+
} finally {
|
|
22701
|
+
this.#inFlight.delete(workId);
|
|
22702
|
+
}
|
|
22703
|
+
if (!this.#closed && (result.kind === "gone" || result.kind === "suspended")) try {
|
|
22704
|
+
await this.#registry.settleRecipient(work.key, registration.generation, result.kind);
|
|
22705
|
+
} catch {}
|
|
22706
|
+
const finalGuard = result.kind === "accepted" && !this.#closed ? await this.#guardKey(work.key, fakeSource, origin, false, registration.generation) : "denied";
|
|
22707
|
+
if (result.kind === "accepted" && finalGuard === "allowed" && !this.#closed) work.resolve();
|
|
22708
|
+
else work.reject(/* @__PURE__ */ new Error("push_test_unavailable"));
|
|
22709
|
+
}
|
|
22710
|
+
async #guard(intent, origin) {
|
|
22711
|
+
return this.#guardKey(intent.key, intent.source, origin, true, intent.recipientGeneration);
|
|
22712
|
+
}
|
|
22713
|
+
/** Maps the Host tri-state for durable-work decisions; boolean eligible() would collapse transient uncertainty into denial. */
|
|
22714
|
+
async #sourceGuard(source) {
|
|
22715
|
+
if (!this.#host.available) return "unverifiable";
|
|
22716
|
+
let result;
|
|
22717
|
+
try {
|
|
22718
|
+
result = await this.#host.reconcile(source);
|
|
22719
|
+
} catch {
|
|
22720
|
+
return "unverifiable";
|
|
22721
|
+
}
|
|
22722
|
+
if (result === "eligible") return "allowed";
|
|
22723
|
+
if (result === "stale") return "denied";
|
|
22724
|
+
if (source.category === "plan" || source.category === "approval" || source.category === "question" && typeof source.callId !== "string") return "denied";
|
|
22725
|
+
return "unverifiable";
|
|
22726
|
+
}
|
|
22727
|
+
async #guardKey(key, source, origin, requireEligible, expectedGeneration) {
|
|
22728
|
+
const epoch = origin.generation;
|
|
22729
|
+
if (this.#closed || !origin.active || this.#origins.get(this.#originId(origin)) !== origin || source.expiresAt <= this.#now()) return "denied";
|
|
22730
|
+
if (!this.#host.available) return "unverifiable";
|
|
22731
|
+
let first;
|
|
22732
|
+
try {
|
|
22733
|
+
first = this.#registration(key);
|
|
22734
|
+
} catch {
|
|
22735
|
+
return "unverifiable";
|
|
22736
|
+
}
|
|
22737
|
+
if (!first || !first.enabled || first.health !== "ready" || expectedGeneration !== void 0 && first.generation !== expectedGeneration || requireEligible && !isPreferenceEnabled(first.preferences, source.category)) return "denied";
|
|
22738
|
+
if (requireEligible) {
|
|
22739
|
+
const guard = await this.#sourceGuard(source);
|
|
22740
|
+
if (guard === "denied") return "denied";
|
|
22741
|
+
if (guard === "unverifiable") return "unverifiable";
|
|
22742
|
+
}
|
|
22743
|
+
const auth = await this.#readAuthorized(origin);
|
|
22744
|
+
if (!auth) return "unverifiable";
|
|
22745
|
+
const device = auth.get(key.deviceId);
|
|
22746
|
+
if (!device || device.expiresAt <= this.#now()) return "denied";
|
|
22747
|
+
if (this.#closed || !origin.active || origin.generation !== epoch || this.#origins.get(this.#originId(origin)) !== origin || source.expiresAt <= this.#now()) return "denied";
|
|
22748
|
+
let latest;
|
|
22749
|
+
let latestFingerprint;
|
|
22750
|
+
try {
|
|
22751
|
+
latest = this.#registration(key);
|
|
22752
|
+
latestFingerprint = this.#registry.snapshot().keys?.fingerprint;
|
|
22753
|
+
} catch {
|
|
22754
|
+
return "unverifiable";
|
|
22755
|
+
}
|
|
22756
|
+
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";
|
|
22757
|
+
return "allowed";
|
|
22758
|
+
}
|
|
22759
|
+
async #ensureReference(intent) {
|
|
22760
|
+
let reference = "";
|
|
22761
|
+
let registration;
|
|
22762
|
+
let keys;
|
|
22763
|
+
try {
|
|
22764
|
+
await this.#write((snapshot) => {
|
|
22765
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22766
|
+
if (!pendingHas(clean, intent)) return clean;
|
|
22767
|
+
registration = clean.registrations.find((row) => sameKey(row, intent.key) && row.generation === intent.recipientGeneration);
|
|
22768
|
+
keys = clean.keys;
|
|
22769
|
+
if (!registration || !keys) return clean;
|
|
22770
|
+
const old = clean.references.find((row) => row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key) && row.expiresAt > this.#now());
|
|
22771
|
+
reference = old?.reference ?? randomBytes(24).toString("base64url");
|
|
22772
|
+
if (!old) {
|
|
22773
|
+
const rows = clean.references.filter((row) => row.key.scopeId !== intent.key.scopeId);
|
|
22774
|
+
const kept = clean.references.filter((row) => row.key.scopeId === intent.key.scopeId).slice(-1023);
|
|
22775
|
+
return {
|
|
22776
|
+
...clean,
|
|
22777
|
+
references: [
|
|
22778
|
+
...rows,
|
|
22779
|
+
...kept,
|
|
22780
|
+
{
|
|
22781
|
+
reference,
|
|
22782
|
+
key: intent.key,
|
|
22783
|
+
source: structuredClone(intent.source),
|
|
22784
|
+
expiresAt: intent.source.expiresAt
|
|
22785
|
+
}
|
|
22786
|
+
]
|
|
22787
|
+
};
|
|
22788
|
+
}
|
|
22789
|
+
return clean;
|
|
22790
|
+
});
|
|
22791
|
+
} catch {
|
|
22792
|
+
this.#defer(intent);
|
|
22793
|
+
return;
|
|
22794
|
+
}
|
|
22795
|
+
return registration && keys && reference ? {
|
|
22796
|
+
registration,
|
|
22797
|
+
keys,
|
|
22798
|
+
reference
|
|
22799
|
+
} : void 0;
|
|
22800
|
+
}
|
|
22801
|
+
async #settleIntent(intent, result) {
|
|
22802
|
+
if (result.kind === "accepted") {
|
|
22803
|
+
await this.#write((snapshot) => {
|
|
22804
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22805
|
+
if (!pendingHas(clean, intent) || intent.source.expiresAt <= this.#now()) return clean;
|
|
22806
|
+
if (!recipientCurrent(clean, intent)) return {
|
|
22807
|
+
...clean,
|
|
22808
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22809
|
+
references: clean.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22810
|
+
};
|
|
22811
|
+
const duplicate = clean.receipts.some((row) => row.sourceId === intent.source.sourceId && sameKey(row.key, intent.key));
|
|
22812
|
+
const rows = clean.receipts.filter((row) => row.key.scopeId !== intent.key.scopeId);
|
|
22813
|
+
const sameScope = clean.receipts.filter((row) => row.key.scopeId === intent.key.scopeId);
|
|
22814
|
+
const receipt = {
|
|
22815
|
+
sourceId: intent.source.sourceId,
|
|
22816
|
+
key: intent.key,
|
|
22817
|
+
recipientGeneration: intent.recipientGeneration,
|
|
22818
|
+
expiresAt: intent.source.expiresAt
|
|
22819
|
+
};
|
|
22820
|
+
const receipts = duplicate ? clean.receipts : [
|
|
22821
|
+
...rows,
|
|
22822
|
+
...sameScope.slice(-1023),
|
|
22823
|
+
receipt
|
|
22824
|
+
];
|
|
22825
|
+
return {
|
|
22826
|
+
...clean,
|
|
22827
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22828
|
+
receipts
|
|
22829
|
+
};
|
|
22830
|
+
});
|
|
22831
|
+
return;
|
|
22832
|
+
}
|
|
22833
|
+
if (result.kind === "gone" || result.kind === "suspended") {
|
|
22834
|
+
await this.#registry.settleRecipient(intent.key, intent.recipientGeneration, result.kind);
|
|
22835
|
+
await this.#removeIntent(intent);
|
|
22836
|
+
return;
|
|
22837
|
+
}
|
|
22838
|
+
if (result.kind === "permanent") {
|
|
22839
|
+
await this.#removeIntent(intent);
|
|
22840
|
+
return;
|
|
22841
|
+
}
|
|
22842
|
+
if (result.kind !== "retry") {
|
|
22843
|
+
await this.#removeIntent(intent);
|
|
22844
|
+
return;
|
|
22845
|
+
}
|
|
22846
|
+
const attempt = intent.attempts + 1;
|
|
22847
|
+
const remainingMs = intent.source.expiresAt - this.#now();
|
|
22848
|
+
if (attempt >= 3 || remainingMs <= 0) {
|
|
22849
|
+
await this.#removeIntent(intent);
|
|
22850
|
+
return;
|
|
22851
|
+
}
|
|
22852
|
+
const retryAfter = result.retryAfterMs;
|
|
22853
|
+
const backoff = RETRY_BACKOFF_MS[attempt - 1] ?? 5e3;
|
|
22854
|
+
const delay = Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : backoff;
|
|
22855
|
+
if (delay >= remainingMs) {
|
|
22856
|
+
await this.#removeIntent(intent);
|
|
22857
|
+
return;
|
|
22858
|
+
}
|
|
22859
|
+
await this.#write((snapshot) => {
|
|
22860
|
+
const clean = boundedSnapshot(snapshot, this.#now());
|
|
22861
|
+
if (!pendingHas(clean, intent)) return clean;
|
|
22862
|
+
if (!recipientCurrent(clean, intent)) return {
|
|
22863
|
+
...clean,
|
|
22864
|
+
pending: clean.pending.filter((row) => row.id !== intent.id),
|
|
22865
|
+
references: clean.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22866
|
+
};
|
|
22867
|
+
return {
|
|
22868
|
+
...clean,
|
|
22869
|
+
pending: clean.pending.map((row) => row.id === intent.id ? {
|
|
22870
|
+
...row,
|
|
22871
|
+
attempts: attempt,
|
|
22872
|
+
nextAttemptAt: this.#now() + delay
|
|
22873
|
+
} : row)
|
|
22874
|
+
};
|
|
22875
|
+
});
|
|
22876
|
+
}
|
|
22877
|
+
async #removePending(intent) {
|
|
22878
|
+
await this.#write((snapshot) => ({
|
|
22879
|
+
...boundedSnapshot(snapshot, this.#now()),
|
|
22880
|
+
pending: snapshot.pending.filter((row) => row.id !== intent.id)
|
|
22881
|
+
}));
|
|
22882
|
+
}
|
|
22883
|
+
async #removeIntent(intent) {
|
|
22884
|
+
await this.#write((snapshot) => ({
|
|
22885
|
+
...boundedSnapshot(snapshot, this.#now()),
|
|
22886
|
+
pending: snapshot.pending.filter((row) => row.id !== intent.id),
|
|
22887
|
+
references: snapshot.references.filter((row) => !(row.source.sourceId === intent.source.sourceId && sameKey(row.key, intent.key)))
|
|
22888
|
+
}));
|
|
22889
|
+
}
|
|
22890
|
+
#defer(intent) {
|
|
22891
|
+
this.#deferredUntil.set(intent.id, this.#now() + UNVERIFIABLE_RECHECK_MS);
|
|
22892
|
+
}
|
|
22893
|
+
/** Defer keys live only as long as their owning pending intent survives every removal path. */
|
|
22894
|
+
#pruneDeferred() {
|
|
22895
|
+
let pending;
|
|
22896
|
+
try {
|
|
22897
|
+
pending = this.#registry.snapshot().pending;
|
|
22898
|
+
} catch {
|
|
22899
|
+
return;
|
|
22900
|
+
}
|
|
22901
|
+
pruneOrphanedDeferrals(this.#deferredUntil, pending);
|
|
22902
|
+
}
|
|
22903
|
+
async #readAuthorized(origin) {
|
|
22904
|
+
try {
|
|
22905
|
+
const rows = await origin.authorizedDevices();
|
|
22906
|
+
if (!Array.isArray(rows)) return void 0;
|
|
22907
|
+
const result = /* @__PURE__ */ new Map();
|
|
22908
|
+
for (const row of rows) {
|
|
22909
|
+
if (!row || typeof row.id !== "string" || row.id.length === 0 || !Number.isFinite(row.expiresAt)) return void 0;
|
|
22910
|
+
result.set(row.id, row);
|
|
22911
|
+
}
|
|
22912
|
+
return result;
|
|
22913
|
+
} catch {
|
|
22914
|
+
return;
|
|
22915
|
+
}
|
|
22916
|
+
}
|
|
22917
|
+
#registration(key) {
|
|
22918
|
+
const snapshot = this.#registry.snapshot();
|
|
22919
|
+
const registration = snapshot.registrations.find((row) => sameKey(row, key));
|
|
22920
|
+
if (!registration || snapshot.keys?.fingerprint !== registration.keyFingerprint) return void 0;
|
|
22921
|
+
return registration;
|
|
22922
|
+
}
|
|
22923
|
+
#originFor(key) {
|
|
22924
|
+
return this.#origins.get(JSON.stringify([key.scopeId, key.origin]));
|
|
22925
|
+
}
|
|
22926
|
+
#originId(origin) {
|
|
22927
|
+
return JSON.stringify([origin.scopeId, origin.origin]);
|
|
22928
|
+
}
|
|
22929
|
+
/** True when the origin object is inactive or has been replaced — a transient state, never authorization evidence. */
|
|
22930
|
+
#originDetached(origin) {
|
|
22931
|
+
return !origin.active || this.#origins.get(this.#originId(origin)) !== origin;
|
|
22932
|
+
}
|
|
22933
|
+
/** Defers rather than removes when the denial could stem from origin state; removal stays reserved for authorization-backed denial. */
|
|
22934
|
+
async #denyOrDefer(intent, origin) {
|
|
22935
|
+
if (this.#originDetached(origin)) this.#defer(intent);
|
|
22936
|
+
else await this.#removeIntent(intent);
|
|
22937
|
+
}
|
|
22938
|
+
#clearOriginDeferrals(origin) {
|
|
22939
|
+
if (this.#deferredUntil.size === 0) return;
|
|
22940
|
+
let pending;
|
|
22941
|
+
try {
|
|
22942
|
+
pending = this.#registry.snapshot().pending;
|
|
22943
|
+
} catch {
|
|
22944
|
+
return;
|
|
22945
|
+
}
|
|
22946
|
+
for (const intent of pending) if (intent.key.scopeId === origin.scopeId && intent.key.origin === origin.origin) this.#deferredUntil.delete(intent.id);
|
|
22947
|
+
}
|
|
22948
|
+
#write(update) {
|
|
22949
|
+
const operation = this.#writeQueue.then(() => this.#registry.transact((snapshot) => update(boundedSnapshot(snapshot, this.#now()))));
|
|
22950
|
+
this.#writeQueue = operation.catch(() => void 0);
|
|
22951
|
+
return operation;
|
|
22952
|
+
}
|
|
22953
|
+
#scheduleWake() {
|
|
22954
|
+
if (this.#closed) return;
|
|
22955
|
+
if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
|
|
22956
|
+
this.#wakeTimer = void 0;
|
|
22957
|
+
this.#pruneDeferred();
|
|
22958
|
+
let nextAt = Number.POSITIVE_INFINITY;
|
|
22959
|
+
try {
|
|
22960
|
+
for (const intent of this.#registry.snapshot().pending) {
|
|
22961
|
+
if (this.#inFlightIds.has(intent.id)) continue;
|
|
22962
|
+
nextAt = Math.min(nextAt, Math.max(intent.nextAttemptAt, this.#deferredUntil.get(intent.id) ?? 0));
|
|
22963
|
+
}
|
|
22964
|
+
} catch {
|
|
22965
|
+
return;
|
|
22966
|
+
}
|
|
22967
|
+
if (this.#tests.length) nextAt = this.#now();
|
|
22968
|
+
if (!Number.isFinite(nextAt)) return;
|
|
22969
|
+
const delay = Math.max(0, nextAt - this.#now());
|
|
22970
|
+
this.#wakeTimer = setTimeout(() => {
|
|
22971
|
+
this.#wakeTimer = void 0;
|
|
22972
|
+
this.#kick();
|
|
22973
|
+
}, delay);
|
|
22974
|
+
}
|
|
22975
|
+
};
|
|
22976
|
+
//#endregion
|
|
22977
|
+
//#region src/mobile-push/gateway.ts
|
|
22978
|
+
/** One service shared by every Gateway while each Gateway owns a separate auth/origin attachment. */
|
|
22979
|
+
var PushGatewayCoordinator = class {
|
|
22980
|
+
host;
|
|
22981
|
+
registry;
|
|
22982
|
+
delivery;
|
|
22983
|
+
origins = /* @__PURE__ */ new Map();
|
|
22984
|
+
closed = false;
|
|
22985
|
+
constructor(host, registry, delivery) {
|
|
22986
|
+
this.host = host;
|
|
22987
|
+
this.registry = registry;
|
|
22988
|
+
this.delivery = delivery;
|
|
22989
|
+
}
|
|
22990
|
+
attachOrigin(binding) {
|
|
22991
|
+
if (this.closed) return () => void 0;
|
|
22992
|
+
if (!binding.scopeId || !binding.origin) throw new Error("push_origin_invalid");
|
|
22993
|
+
let parsed;
|
|
22994
|
+
try {
|
|
22995
|
+
parsed = new URL(binding.origin);
|
|
22996
|
+
} catch {
|
|
22997
|
+
throw new Error("push_origin_invalid");
|
|
22998
|
+
}
|
|
22999
|
+
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");
|
|
23000
|
+
const id = JSON.stringify([binding.scopeId, binding.origin]);
|
|
23001
|
+
if (this.origins.has(id)) throw new Error("push_origin_already_attached");
|
|
23002
|
+
const detachDelivery = this.delivery.attachOrigin(binding);
|
|
23003
|
+
const row = {
|
|
23004
|
+
binding,
|
|
23005
|
+
detachDelivery
|
|
23006
|
+
};
|
|
23007
|
+
this.origins.set(id, row);
|
|
23008
|
+
let active = true;
|
|
23009
|
+
return () => {
|
|
23010
|
+
if (!active) return;
|
|
23011
|
+
active = false;
|
|
23012
|
+
if (this.origins.get(id) === row) this.origins.delete(id);
|
|
23013
|
+
detachDelivery();
|
|
23014
|
+
};
|
|
23015
|
+
}
|
|
23016
|
+
async invalidateDevices(scopeId, deviceIds) {
|
|
23017
|
+
await this.delivery.invalidateDevices(scopeId, deviceIds);
|
|
23018
|
+
}
|
|
23019
|
+
async reset() {
|
|
23020
|
+
await this.registry.resetKeys();
|
|
23021
|
+
}
|
|
23022
|
+
async config(owner, installationId) {
|
|
23023
|
+
const authorized = await this.authorize(owner);
|
|
23024
|
+
const config = await this.registry.config(owner, installationId);
|
|
23025
|
+
return {
|
|
23026
|
+
...config,
|
|
23027
|
+
available: config.available && this.host.available && authorized
|
|
23028
|
+
};
|
|
23029
|
+
}
|
|
23030
|
+
async enroll(owner, input) {
|
|
23031
|
+
if (!this.host.available) throw new Error("push_unavailable");
|
|
23032
|
+
await this.authorize(owner);
|
|
23033
|
+
let subscription;
|
|
23034
|
+
try {
|
|
23035
|
+
subscription = validatePushSubscription(input.subscription);
|
|
23036
|
+
} catch (error) {
|
|
23037
|
+
throw new Error(error instanceof Error ? error.message : "push_subscription_invalid");
|
|
23038
|
+
}
|
|
23039
|
+
return this.registry.upsert(owner, {
|
|
23040
|
+
...input,
|
|
23041
|
+
subscription
|
|
23042
|
+
});
|
|
23043
|
+
}
|
|
23044
|
+
async preferences(owner, installationId, value, guard) {
|
|
23045
|
+
await this.authorize(owner);
|
|
23046
|
+
return this.registry.preferences({
|
|
23047
|
+
...owner,
|
|
23048
|
+
installationId
|
|
23049
|
+
}, value, guard);
|
|
23050
|
+
}
|
|
23051
|
+
async disable(owner, installationId, ifGeneration) {
|
|
23052
|
+
await this.authorize(owner);
|
|
23053
|
+
await this.registry.disable({
|
|
23054
|
+
...owner,
|
|
23055
|
+
installationId
|
|
23056
|
+
}, ifGeneration);
|
|
23057
|
+
}
|
|
23058
|
+
async logout(owner) {
|
|
23059
|
+
await this.authorize(owner);
|
|
23060
|
+
await this.registry.disableOwner(owner);
|
|
23061
|
+
}
|
|
23062
|
+
async test(owner, installationId) {
|
|
23063
|
+
if (!this.host.available) throw new Error("push_unavailable");
|
|
23064
|
+
await this.authorize(owner);
|
|
23065
|
+
await this.delivery.test({
|
|
23066
|
+
...owner,
|
|
23067
|
+
installationId
|
|
23068
|
+
});
|
|
23069
|
+
}
|
|
23070
|
+
async resolve(owner, reference) {
|
|
23071
|
+
if (!this.host.available || !await this.authorize(owner, true)) return void 0;
|
|
23072
|
+
return this.delivery.resolve(owner, reference);
|
|
23073
|
+
}
|
|
23074
|
+
/** Close only this service's origin attachments; its owning plugin closes shared Delivery/Host/Registry. */
|
|
23075
|
+
close() {
|
|
23076
|
+
if (this.closed) return;
|
|
23077
|
+
this.closed = true;
|
|
23078
|
+
for (const row of this.origins.values()) row.detachDelivery();
|
|
23079
|
+
this.origins.clear();
|
|
23080
|
+
}
|
|
23081
|
+
async authorize(owner, neutralMissing = false) {
|
|
23082
|
+
const row = this.origins.get(JSON.stringify([owner.scopeId, owner.origin]));
|
|
23083
|
+
if (!row) {
|
|
23084
|
+
if (neutralMissing) return false;
|
|
23085
|
+
throw new Error("push_unavailable");
|
|
23086
|
+
}
|
|
23087
|
+
let devices;
|
|
23088
|
+
try {
|
|
23089
|
+
devices = await row.binding.authorizedDevices();
|
|
23090
|
+
} catch {
|
|
23091
|
+
throw new Error("push_unavailable");
|
|
23092
|
+
}
|
|
23093
|
+
if (!devices.some((device) => device.id === owner.deviceId && device.expiresAt > Date.now())) {
|
|
23094
|
+
if (neutralMissing) return false;
|
|
23095
|
+
throw new Error("push_unauthorized");
|
|
23096
|
+
}
|
|
23097
|
+
return true;
|
|
23098
|
+
}
|
|
23099
|
+
};
|
|
23100
|
+
//#endregion
|
|
23101
|
+
//#region src/installation-id.ts
|
|
23102
|
+
const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
|
|
23103
|
+
const INSTALLATION_ID_BYTES = 65;
|
|
23104
|
+
async function readInstallationId(file) {
|
|
23105
|
+
const entry = await lstat(file);
|
|
23106
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== INSTALLATION_ID_BYTES) throw new Error("installation_id_invalid");
|
|
23107
|
+
const contents = await readFile(file, "utf8");
|
|
23108
|
+
if (!INSTALLATION_ID_PATTERN.test(contents)) throw new Error("installation_id_invalid");
|
|
23109
|
+
await restrictPrivateFile(file);
|
|
23110
|
+
return contents.slice(0, -1);
|
|
23111
|
+
}
|
|
23112
|
+
/** Keep one random plugin identity across setup changes, restarts, and FRP resets; creation requires hard-link support in the state directory. */
|
|
23113
|
+
async function loadOrCreateInstallationId(file) {
|
|
23114
|
+
if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
|
|
23115
|
+
const target = resolve(file);
|
|
23116
|
+
const directory = dirname(target);
|
|
23117
|
+
await ensureManagedDirectory(directory, directory, "installation_id");
|
|
23118
|
+
try {
|
|
23119
|
+
return await readInstallationId(target);
|
|
23120
|
+
} catch (error) {
|
|
23121
|
+
if (error.code !== "ENOENT") throw error;
|
|
23122
|
+
}
|
|
23123
|
+
const temporary = join(directory, `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
23124
|
+
let created = false;
|
|
23125
|
+
let failure;
|
|
23126
|
+
try {
|
|
23127
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
23128
|
+
const handle = await open(temporary, "wx", 384);
|
|
23129
|
+
created = true;
|
|
23130
|
+
let writeFailure;
|
|
23131
|
+
try {
|
|
23132
|
+
await handle.writeFile(`${randomBytes(32).toString("hex")}\n`, "utf8");
|
|
23133
|
+
} catch (error) {
|
|
23134
|
+
writeFailure = error;
|
|
23135
|
+
throw error;
|
|
23136
|
+
} finally {
|
|
23137
|
+
try {
|
|
23138
|
+
await handle.close();
|
|
23139
|
+
} catch (closeError) {
|
|
23140
|
+
if (writeFailure !== void 0) throw new AggregateError([writeFailure, closeError], "installation ID candidate write and close failed");
|
|
23141
|
+
throw closeError;
|
|
23142
|
+
}
|
|
23143
|
+
}
|
|
23144
|
+
await restrictPrivateFile(temporary);
|
|
23145
|
+
await assertManagedParents(directory, target, "installation_id");
|
|
23146
|
+
try {
|
|
23147
|
+
await link(temporary, target);
|
|
23148
|
+
} catch (error) {
|
|
23149
|
+
if (error.code !== "EEXIST") throw new Error("installation ID publication failed; the state filesystem must support hard links", { cause: error });
|
|
23150
|
+
}
|
|
23151
|
+
return await readInstallationId(target);
|
|
23152
|
+
} catch (error) {
|
|
23153
|
+
failure = error;
|
|
23154
|
+
throw error;
|
|
23155
|
+
} finally {
|
|
23156
|
+
if (created) try {
|
|
23157
|
+
await assertManagedParents(directory, temporary, "installation_id");
|
|
23158
|
+
await unlink(temporary);
|
|
23159
|
+
} catch (cleanupError) {
|
|
23160
|
+
if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
|
|
23161
|
+
throw cleanupError;
|
|
23162
|
+
}
|
|
23163
|
+
}
|
|
23164
|
+
}
|
|
23165
|
+
//#endregion
|
|
23166
|
+
//#region src/gateway-identity.ts
|
|
23167
|
+
/**
|
|
23168
|
+
* Attach the authenticated installation identity while retaining existing entry identity rules.
|
|
23169
|
+
* @param config - Validated gateway configuration containing the legacy identity.
|
|
23170
|
+
* @param installationId - Confirmed random identity read from this installation's state.
|
|
23171
|
+
* @param explicitIdentity - Whether the operator supplied the gateway identity.
|
|
19767
23172
|
* @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
|
|
19768
23173
|
*/
|
|
19769
23174
|
function withInstallationIdentity(config, installationId, explicitIdentity) {
|
|
@@ -19861,6 +23266,31 @@ const inject = [
|
|
|
19861
23266
|
"commands",
|
|
19862
23267
|
"connection"
|
|
19863
23268
|
];
|
|
23269
|
+
const pushHostServices = [
|
|
23270
|
+
"userQuestions",
|
|
23271
|
+
"approval",
|
|
23272
|
+
"sessionProjections",
|
|
23273
|
+
"sessionQuery",
|
|
23274
|
+
"agents"
|
|
23275
|
+
];
|
|
23276
|
+
const unavailablePushPreferences = Object.freeze({
|
|
23277
|
+
question: true,
|
|
23278
|
+
plan: true,
|
|
23279
|
+
approval: true,
|
|
23280
|
+
response: true,
|
|
23281
|
+
goal: true,
|
|
23282
|
+
failure: true
|
|
23283
|
+
});
|
|
23284
|
+
function unavailablePushConfig(installationId) {
|
|
23285
|
+
return {
|
|
23286
|
+
available: false,
|
|
23287
|
+
installationId,
|
|
23288
|
+
ownerGeneration: 0,
|
|
23289
|
+
registrationGeneration: 0,
|
|
23290
|
+
status: "disabled",
|
|
23291
|
+
preferences: unavailablePushPreferences
|
|
23292
|
+
};
|
|
23293
|
+
}
|
|
19864
23294
|
/** Run cleanup steps in ownership order and report every failure after all steps settle. */
|
|
19865
23295
|
async function settleCleanupSteps(steps) {
|
|
19866
23296
|
const errors = [];
|
|
@@ -19908,6 +23338,7 @@ function installedDshVersion() {
|
|
|
19908
23338
|
function mapAdminError(error) {
|
|
19909
23339
|
if (error instanceof HttpError) return error;
|
|
19910
23340
|
if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
|
|
23341
|
+
if (error instanceof Error && error.message === "remote_provider_changed") return new HttpError(409, "remote_provider_changed");
|
|
19911
23342
|
const code = error.code;
|
|
19912
23343
|
if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
|
|
19913
23344
|
if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
|
|
@@ -20205,6 +23636,210 @@ async function apply(ctx, config) {
|
|
|
20205
23636
|
logger.info("task event=%s fields=%o", event, fields);
|
|
20206
23637
|
}
|
|
20207
23638
|
});
|
|
23639
|
+
let pushHost;
|
|
23640
|
+
let pushRegistry;
|
|
23641
|
+
let pushDelivery;
|
|
23642
|
+
let pushGatewayService;
|
|
23643
|
+
let pushRuntimeReady;
|
|
23644
|
+
let resolvePushRuntimeReady;
|
|
23645
|
+
let activePushRuntimeGeneration;
|
|
23646
|
+
const pushOriginAttachments = /* @__PURE__ */ new Map();
|
|
23647
|
+
const pendingDeviceInvalidations = /* @__PURE__ */ new Map();
|
|
23648
|
+
const beginPushRuntimeReadiness = () => {
|
|
23649
|
+
if (pushGatewayService !== void 0) return Promise.resolve(pushGatewayService);
|
|
23650
|
+
pushRuntimeReady ??= new Promise((resolve) => {
|
|
23651
|
+
resolvePushRuntimeReady = resolve;
|
|
23652
|
+
});
|
|
23653
|
+
return pushRuntimeReady;
|
|
23654
|
+
};
|
|
23655
|
+
const settlePushRuntimeReadiness = (service) => {
|
|
23656
|
+
const resolve = resolvePushRuntimeReady;
|
|
23657
|
+
resolvePushRuntimeReady = void 0;
|
|
23658
|
+
pushRuntimeReady = void 0;
|
|
23659
|
+
resolve?.(service);
|
|
23660
|
+
};
|
|
23661
|
+
const setPushGatewayService = (service) => {
|
|
23662
|
+
for (const attachment of pushOriginAttachments.values()) {
|
|
23663
|
+
try {
|
|
23664
|
+
attachment.detach?.();
|
|
23665
|
+
} catch {
|
|
23666
|
+
logger.warn("declarative push origin detach failed");
|
|
23667
|
+
}
|
|
23668
|
+
attachment.detach = void 0;
|
|
23669
|
+
}
|
|
23670
|
+
pushGatewayService = service;
|
|
23671
|
+
if (service !== void 0) for (const attachment of pushOriginAttachments.values()) try {
|
|
23672
|
+
attachment.detach = service.attachOrigin(attachment.binding);
|
|
23673
|
+
} catch {
|
|
23674
|
+
logger.warn("declarative push origin attach failed; Mobile access remains active");
|
|
23675
|
+
}
|
|
23676
|
+
settlePushRuntimeReadiness(service);
|
|
23677
|
+
};
|
|
23678
|
+
const currentPushGatewayService = async () => {
|
|
23679
|
+
if (pushGatewayService !== void 0) return pushGatewayService;
|
|
23680
|
+
return pushRuntimeReady === void 0 ? void 0 : pushRuntimeReady;
|
|
23681
|
+
};
|
|
23682
|
+
const requiredPushGatewayService = async () => {
|
|
23683
|
+
const service = await currentPushGatewayService();
|
|
23684
|
+
if (service === void 0) throw new Error("push_unavailable");
|
|
23685
|
+
return service;
|
|
23686
|
+
};
|
|
23687
|
+
const queueDeviceInvalidations = (scopeId, deviceIds) => {
|
|
23688
|
+
let queued = pendingDeviceInvalidations.get(scopeId);
|
|
23689
|
+
if (queued === void 0) pendingDeviceInvalidations.set(scopeId, queued = /* @__PURE__ */ new Set());
|
|
23690
|
+
for (const deviceId of deviceIds) queued.add(deviceId);
|
|
23691
|
+
};
|
|
23692
|
+
const flushDeviceInvalidations = async (service) => {
|
|
23693
|
+
while (pendingDeviceInvalidations.size > 0) {
|
|
23694
|
+
const batch = [...pendingDeviceInvalidations];
|
|
23695
|
+
pendingDeviceInvalidations.clear();
|
|
23696
|
+
for (const [scopeId, deviceIds] of batch) await service.invalidateDevices(scopeId, [...deviceIds]);
|
|
23697
|
+
}
|
|
23698
|
+
};
|
|
23699
|
+
const pushGatewayFacade = {
|
|
23700
|
+
attachOrigin(binding) {
|
|
23701
|
+
const id = Symbol("dsh-mobile push origin");
|
|
23702
|
+
const attachment = {
|
|
23703
|
+
binding,
|
|
23704
|
+
detach: void 0
|
|
23705
|
+
};
|
|
23706
|
+
pushOriginAttachments.set(id, attachment);
|
|
23707
|
+
try {
|
|
23708
|
+
if (pushGatewayService !== void 0) attachment.detach = pushGatewayService.attachOrigin(binding);
|
|
23709
|
+
} catch (error) {
|
|
23710
|
+
pushOriginAttachments.delete(id);
|
|
23711
|
+
throw error;
|
|
23712
|
+
}
|
|
23713
|
+
let active = true;
|
|
23714
|
+
return () => {
|
|
23715
|
+
if (!active) return;
|
|
23716
|
+
active = false;
|
|
23717
|
+
pushOriginAttachments.delete(id);
|
|
23718
|
+
try {
|
|
23719
|
+
attachment.detach?.();
|
|
23720
|
+
} finally {
|
|
23721
|
+
attachment.detach = void 0;
|
|
23722
|
+
}
|
|
23723
|
+
};
|
|
23724
|
+
},
|
|
23725
|
+
async invalidateDevices(scopeId, deviceIds) {
|
|
23726
|
+
if (pushGatewayService !== void 0) {
|
|
23727
|
+
await pushGatewayService.invalidateDevices(scopeId, deviceIds);
|
|
23728
|
+
return;
|
|
23729
|
+
}
|
|
23730
|
+
queueDeviceInvalidations(scopeId, deviceIds);
|
|
23731
|
+
},
|
|
23732
|
+
async reset() {
|
|
23733
|
+
await (await requiredPushGatewayService()).reset();
|
|
23734
|
+
},
|
|
23735
|
+
async config(owner, installationId) {
|
|
23736
|
+
const service = await currentPushGatewayService();
|
|
23737
|
+
return service === void 0 ? unavailablePushConfig(installationId) : service.config(owner, installationId);
|
|
23738
|
+
},
|
|
23739
|
+
async enroll(owner, input) {
|
|
23740
|
+
return (await requiredPushGatewayService()).enroll(owner, input);
|
|
23741
|
+
},
|
|
23742
|
+
async preferences(owner, installationId, value, guard) {
|
|
23743
|
+
return (await requiredPushGatewayService()).preferences(owner, installationId, value, guard);
|
|
23744
|
+
},
|
|
23745
|
+
async disable(owner, installationId, ifGeneration) {
|
|
23746
|
+
await (await requiredPushGatewayService()).disable(owner, installationId, ifGeneration);
|
|
23747
|
+
},
|
|
23748
|
+
async logout(owner) {
|
|
23749
|
+
const service = await currentPushGatewayService();
|
|
23750
|
+
if (service !== void 0) {
|
|
23751
|
+
await service.logout(owner);
|
|
23752
|
+
return;
|
|
23753
|
+
}
|
|
23754
|
+
if (activePushRuntimeGeneration !== void 0 || pushRegistry !== void 0) throw new Error("push_unavailable");
|
|
23755
|
+
const saved = await new JsonPushStore(join(stateDirectory, "mobile-push", "push-state.json")).load();
|
|
23756
|
+
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");
|
|
23757
|
+
},
|
|
23758
|
+
async test(owner, installationId) {
|
|
23759
|
+
await (await requiredPushGatewayService()).test(owner, installationId);
|
|
23760
|
+
},
|
|
23761
|
+
async resolve(owner, reference) {
|
|
23762
|
+
return (await requiredPushGatewayService()).resolve(owner, reference);
|
|
23763
|
+
}
|
|
23764
|
+
};
|
|
23765
|
+
const closePushRuntime = async (coordinatorOverride) => {
|
|
23766
|
+
const coordinator = coordinatorOverride ?? pushGatewayService;
|
|
23767
|
+
if (pushGatewayService === coordinator) setPushGatewayService(void 0);
|
|
23768
|
+
else if (coordinator === void 0) settlePushRuntimeReadiness(void 0);
|
|
23769
|
+
const delivery = pushDelivery;
|
|
23770
|
+
pushDelivery = void 0;
|
|
23771
|
+
const registry = pushRegistry;
|
|
23772
|
+
pushRegistry = void 0;
|
|
23773
|
+
const host = pushHost;
|
|
23774
|
+
pushHost = void 0;
|
|
23775
|
+
await settleCleanupSteps([
|
|
23776
|
+
() => coordinator?.close(),
|
|
23777
|
+
() => delivery?.close(),
|
|
23778
|
+
() => registry?.close(),
|
|
23779
|
+
() => host?.close()
|
|
23780
|
+
]);
|
|
23781
|
+
};
|
|
23782
|
+
const initializePushRuntime = async (pushContext, isActive) => {
|
|
23783
|
+
try {
|
|
23784
|
+
pushHost = createPushHost(pushContext);
|
|
23785
|
+
if (!pushHost.available) {
|
|
23786
|
+
logger.info("declarative push unavailable: this DSH Host lacks the required public notification services");
|
|
23787
|
+
return;
|
|
23788
|
+
}
|
|
23789
|
+
pushRegistry = new PushRegistry(new JsonPushStore(join(stateDirectory, "mobile-push", "push-state.json")), createVapidKeys);
|
|
23790
|
+
await pushRegistry.initialize();
|
|
23791
|
+
if (!isActive()) return;
|
|
23792
|
+
pushDelivery = new PushDelivery({
|
|
23793
|
+
host: pushHost,
|
|
23794
|
+
registry: pushRegistry,
|
|
23795
|
+
sender: new PushSender()
|
|
23796
|
+
});
|
|
23797
|
+
await pushDelivery.start();
|
|
23798
|
+
if (!isActive()) return;
|
|
23799
|
+
const coordinator = new PushGatewayCoordinator(pushHost, pushRegistry, pushDelivery);
|
|
23800
|
+
await flushDeviceInvalidations(coordinator);
|
|
23801
|
+
if (!isActive()) return;
|
|
23802
|
+
setPushGatewayService(coordinator);
|
|
23803
|
+
} catch (error) {
|
|
23804
|
+
logger.warn("declarative push unavailable; Mobile access remains active (%s)", error instanceof Error ? error.name : "initialization-failed");
|
|
23805
|
+
if (isActive()) try {
|
|
23806
|
+
await closePushRuntime();
|
|
23807
|
+
} catch {
|
|
23808
|
+
logger.warn("declarative push cleanup failed; Mobile access remains active");
|
|
23809
|
+
}
|
|
23810
|
+
}
|
|
23811
|
+
};
|
|
23812
|
+
const startPushRuntimeGeneration = (pushContext) => {
|
|
23813
|
+
const generation = {
|
|
23814
|
+
active: true,
|
|
23815
|
+
ready: beginPushRuntimeReadiness(),
|
|
23816
|
+
initialization: Promise.resolve()
|
|
23817
|
+
};
|
|
23818
|
+
activePushRuntimeGeneration = generation;
|
|
23819
|
+
generation.initialization = (async () => {
|
|
23820
|
+
await initializePushRuntime(pushContext, () => generation.active);
|
|
23821
|
+
if (generation.active && pushGatewayService === void 0) settlePushRuntimeReadiness(void 0);
|
|
23822
|
+
})();
|
|
23823
|
+
return generation;
|
|
23824
|
+
};
|
|
23825
|
+
const closePushRuntimeGeneration = (generation) => {
|
|
23826
|
+
if (generation.closePromise !== void 0) return generation.closePromise;
|
|
23827
|
+
generation.active = false;
|
|
23828
|
+
if (activePushRuntimeGeneration !== generation) {
|
|
23829
|
+
generation.closePromise = generation.initialization;
|
|
23830
|
+
return generation.closePromise;
|
|
23831
|
+
}
|
|
23832
|
+
const coordinator = pushGatewayService;
|
|
23833
|
+
if (coordinator !== void 0) setPushGatewayService(void 0);
|
|
23834
|
+
else settlePushRuntimeReadiness(void 0);
|
|
23835
|
+
generation.closePromise = (async () => {
|
|
23836
|
+
await generation.initialization;
|
|
23837
|
+
if (activePushRuntimeGeneration !== generation) return;
|
|
23838
|
+
activePushRuntimeGeneration = void 0;
|
|
23839
|
+
await closePushRuntime(coordinator);
|
|
23840
|
+
})();
|
|
23841
|
+
return generation.closePromise;
|
|
23842
|
+
};
|
|
20208
23843
|
const remoteDirectory = join(stateDirectory, "remote");
|
|
20209
23844
|
const configuredDshHome = process.env.DSH_HOME?.trim();
|
|
20210
23845
|
const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
|
|
@@ -20292,7 +23927,7 @@ async function apply(ctx, config) {
|
|
|
20292
23927
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20293
23928
|
}, (entry) => {
|
|
20294
23929
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20295
|
-
});
|
|
23930
|
+
}, pushGatewayFacade);
|
|
20296
23931
|
await candidate.start();
|
|
20297
23932
|
lanGateway = candidate;
|
|
20298
23933
|
const removeTaskSink = taskEventHub.add(candidate);
|
|
@@ -20356,7 +23991,7 @@ async function apply(ctx, config) {
|
|
|
20356
23991
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20357
23992
|
}, (entry) => {
|
|
20358
23993
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20359
|
-
});
|
|
23994
|
+
}, pushGatewayFacade);
|
|
20360
23995
|
await candidate.start();
|
|
20361
23996
|
return candidate;
|
|
20362
23997
|
};
|
|
@@ -20408,7 +24043,7 @@ async function apply(ctx, config) {
|
|
|
20408
24043
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20409
24044
|
}, (entry) => {
|
|
20410
24045
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20411
|
-
});
|
|
24046
|
+
}, pushGatewayFacade);
|
|
20412
24047
|
try {
|
|
20413
24048
|
await candidate.start();
|
|
20414
24049
|
} catch (error) {
|
|
@@ -20427,7 +24062,7 @@ async function apply(ctx, config) {
|
|
|
20427
24062
|
logger.info("upstream auth %s", JSON.stringify(entry));
|
|
20428
24063
|
}, (entry) => {
|
|
20429
24064
|
logger.warn("slow api %s", JSON.stringify(entry));
|
|
20430
|
-
});
|
|
24065
|
+
}, pushGatewayFacade);
|
|
20431
24066
|
try {
|
|
20432
24067
|
await candidate.start();
|
|
20433
24068
|
} catch (error) {
|
|
@@ -20656,9 +24291,19 @@ async function apply(ctx, config) {
|
|
|
20656
24291
|
handler: async (request, response) => {
|
|
20657
24292
|
try {
|
|
20658
24293
|
const target = parseRequestTarget(request.url);
|
|
20659
|
-
|
|
24294
|
+
const pushReset = target.decodedPathname === `${LOCAL_ADMIN_PREFIX}/push/reset`;
|
|
24295
|
+
assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), pushReset ? [] : baseTemplate.adminAllowedCidrs);
|
|
20660
24296
|
if (adminClosing) throw new HttpError(503, "unavailable");
|
|
20661
24297
|
if (target.search !== "") throw new HttpError(400, "bad_request");
|
|
24298
|
+
if (pushReset) {
|
|
24299
|
+
if (request.method !== "POST") throw new HttpError(405, "method_not_allowed");
|
|
24300
|
+
const body = await readJsonObject(request, 1024);
|
|
24301
|
+
if (Object.keys(body).length !== 1 || body.confirm !== "reset-web-push") throw new HttpError(400, "bad_request");
|
|
24302
|
+
if (pushGatewayService === void 0) throw new HttpError(503, "push_unavailable");
|
|
24303
|
+
await pushGatewayService.reset();
|
|
24304
|
+
sendJson(response, 200, { reset: true }, false);
|
|
24305
|
+
return;
|
|
24306
|
+
}
|
|
20662
24307
|
const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
|
|
20663
24308
|
if (request.method === "GET" && lanControl) {
|
|
20664
24309
|
sendJson(response, 200, lanPayload(), false);
|
|
@@ -20918,10 +24563,14 @@ async function apply(ctx, config) {
|
|
|
20918
24563
|
}
|
|
20919
24564
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/cloudflared/tunnel`) {
|
|
20920
24565
|
const body = await readJsonObject(request, 8192);
|
|
20921
|
-
|
|
24566
|
+
if (body.connect !== void 0 && typeof body.connect !== "boolean") throw new HttpError(400, "bad_request");
|
|
24567
|
+
const configure = async () => {
|
|
20922
24568
|
await cloudflaredTunnel.configure(mergeSavedCloudflaredTunnelSettings(body, cloudflaredTunnel.settings()));
|
|
20923
|
-
if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.
|
|
20924
|
-
|
|
24569
|
+
if (body.connect === true && !remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.setEnabled(true);
|
|
24570
|
+
else if (remoteControllers.cloudflared.status().enabled) await remoteControllers.cloudflared.reconnect();
|
|
24571
|
+
};
|
|
24572
|
+
if (body.connect === true) await remoteProviders.mutateSelected("cloudflared", configure);
|
|
24573
|
+
else await remoteProviders.mutate(configure);
|
|
20925
24574
|
sendJson(response, 200, remotePayload(), false);
|
|
20926
24575
|
return;
|
|
20927
24576
|
}
|
|
@@ -20969,6 +24618,23 @@ async function apply(ctx, config) {
|
|
|
20969
24618
|
sendJson(response, 200, remotePayload(), false);
|
|
20970
24619
|
return;
|
|
20971
24620
|
}
|
|
24621
|
+
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/component/purge`) {
|
|
24622
|
+
if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
|
|
24623
|
+
logger.info("iroh component purge started");
|
|
24624
|
+
try {
|
|
24625
|
+
await remoteProviders.mutate(async () => {
|
|
24626
|
+
if (remoteControllers.iroh.status().enabled) throw new HttpError(409, "iroh_component_running");
|
|
24627
|
+
await remoteControllers.iroh.setEnabled(false);
|
|
24628
|
+
await irohComponent.purge();
|
|
24629
|
+
});
|
|
24630
|
+
logger.info("iroh component purge completed");
|
|
24631
|
+
} catch (error) {
|
|
24632
|
+
logger.error("iroh component purge failed: %s", error instanceof Error ? error.stack ?? error.message : String(error));
|
|
24633
|
+
throw error;
|
|
24634
|
+
}
|
|
24635
|
+
sendJson(response, 200, remotePayload(), false);
|
|
24636
|
+
return;
|
|
24637
|
+
}
|
|
20972
24638
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/iroh/pairing/begin`) {
|
|
20973
24639
|
await readJsonObject(request, 4096);
|
|
20974
24640
|
logger.info("iroh pairing window requested");
|
|
@@ -21134,9 +24800,15 @@ async function apply(ctx, config) {
|
|
|
21134
24800
|
return;
|
|
21135
24801
|
}
|
|
21136
24802
|
if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/control`) {
|
|
21137
|
-
const
|
|
24803
|
+
const body = await readJsonObject(request, 4096);
|
|
24804
|
+
const running = body.running;
|
|
21138
24805
|
if (typeof running !== "boolean") throw new HttpError(400, "bad_request");
|
|
21139
|
-
|
|
24806
|
+
if (body.provider !== void 0 && body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin" && body.provider !== "iroh") throw new HttpError(400, "bad_request");
|
|
24807
|
+
const control = async (controller) => {
|
|
24808
|
+
await controller.setEnabled(running);
|
|
24809
|
+
};
|
|
24810
|
+
if (body.provider !== void 0) await remoteProviders.mutateSelected(body.provider, control);
|
|
24811
|
+
else await remoteProviders.mutate(control);
|
|
21140
24812
|
sendJson(response, 200, remotePayload(), false);
|
|
21141
24813
|
return;
|
|
21142
24814
|
}
|
|
@@ -21234,7 +24906,20 @@ async function apply(ctx, config) {
|
|
|
21234
24906
|
};
|
|
21235
24907
|
}
|
|
21236
24908
|
});
|
|
24909
|
+
let disposePushRuntimeInjection;
|
|
21237
24910
|
try {
|
|
24911
|
+
const pushHostPresent = pushHostServices.every((service) => ctx.get(service) !== void 0);
|
|
24912
|
+
if (pushHostPresent) beginPushRuntimeReadiness();
|
|
24913
|
+
const pushRuntimeFiber = ctx.inject(pushHostServices, (pushContext) => {
|
|
24914
|
+
const generation = startPushRuntimeGeneration(pushContext);
|
|
24915
|
+
return () => closePushRuntimeGeneration(generation);
|
|
24916
|
+
});
|
|
24917
|
+
disposePushRuntimeInjection = () => pushRuntimeFiber.dispose();
|
|
24918
|
+
if (pushHostPresent) {
|
|
24919
|
+
await pushRuntimeFiber.await();
|
|
24920
|
+
const generation = activePushRuntimeGeneration;
|
|
24921
|
+
if (generation !== void 0) await generation.ready;
|
|
24922
|
+
}
|
|
21238
24923
|
await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
|
|
21239
24924
|
await lanController.initialize();
|
|
21240
24925
|
const stores = {
|
|
@@ -21256,6 +24941,7 @@ async function apply(ctx, config) {
|
|
|
21256
24941
|
await settleCleanupSteps([
|
|
21257
24942
|
stopAdmin,
|
|
21258
24943
|
drainRemoteWork,
|
|
24944
|
+
() => releaseManager.dispose(),
|
|
21259
24945
|
() => trustedNetworksWrite,
|
|
21260
24946
|
disposeMobileCommand,
|
|
21261
24947
|
disposeTaskEvents,
|
|
@@ -21264,6 +24950,8 @@ async function apply(ctx, config) {
|
|
|
21264
24950
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
21265
24951
|
},
|
|
21266
24952
|
() => lanController.close(),
|
|
24953
|
+
() => disposePushRuntimeInjection?.(),
|
|
24954
|
+
closePushRuntime,
|
|
21267
24955
|
() => mobileAccess.stopLocal(),
|
|
21268
24956
|
unregisterBuiltin
|
|
21269
24957
|
]);
|
|
@@ -21276,6 +24964,7 @@ async function apply(ctx, config) {
|
|
|
21276
24964
|
await settleCleanupSteps([
|
|
21277
24965
|
stopAdmin,
|
|
21278
24966
|
drainRemoteWork,
|
|
24967
|
+
() => releaseManager.dispose(),
|
|
21279
24968
|
() => trustedNetworksWrite,
|
|
21280
24969
|
disposeMobileCommand,
|
|
21281
24970
|
disposeTaskEvents,
|
|
@@ -21284,6 +24973,8 @@ async function apply(ctx, config) {
|
|
|
21284
24973
|
if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
|
|
21285
24974
|
},
|
|
21286
24975
|
() => lanController.close(),
|
|
24976
|
+
() => disposePushRuntimeInjection?.(),
|
|
24977
|
+
closePushRuntime,
|
|
21287
24978
|
() => mobileAccess.stopLocal(),
|
|
21288
24979
|
unregisterBuiltin
|
|
21289
24980
|
]);
|