dsh-mobile 0.7.1 → 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/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
- for (const [key, session] of this.sessions) if (session.deviceId === deviceId) this.removeSession(key, "revoked");
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
- for (const key of [...this.sessions.keys()]) this.removeSession(key, "expired");
413
- this.sessionEndedListeners.clear();
414
- this.initialized = false;
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) => attributeValue(match[0], "src") === MOBILE_COMPAT_PATH)) return html;
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 attribute = nonce === "" ? "" : ` nonce="${nonce}"`;
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 : void 0;
6854
- if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
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 etag = `"${createHash("sha256").update(body).digest("hex")}"`;
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": body.byteLength
7378
+ "Content-Length": payload.byteLength
6970
7379
  });
6971
7380
  if (request.method === "HEAD") response.end();
6972
- else response.end(body);
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);
@@ -19771,75 +20209,2964 @@ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
19771
20209
  return file;
19772
20210
  }
19773
20211
  //#endregion
19774
- //#region src/installation-id.ts
19775
- const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
19776
- const INSTALLATION_ID_BYTES = 65;
19777
- async function readInstallationId(file) {
19778
- const entry = await lstat(file);
19779
- if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== INSTALLATION_ID_BYTES) throw new Error("installation_id_invalid");
19780
- const contents = await readFile(file, "utf8");
19781
- if (!INSTALLATION_ID_PATTERN.test(contents)) throw new Error("installation_id_invalid");
19782
- await restrictPrivateFile(file);
19783
- return contents.slice(0, -1);
19784
- }
19785
- /** Keep one random plugin identity across setup changes, restarts, and FRP resets; creation requires hard-link support in the state directory. */
19786
- async function loadOrCreateInstallationId(file) {
19787
- if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
19788
- const target = resolve(file);
19789
- const directory = dirname(target);
19790
- await ensureManagedDirectory(directory, directory, "installation_id");
19791
- try {
19792
- return await readInstallationId(target);
19793
- } catch (error) {
19794
- if (error.code !== "ENOENT") throw error;
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);
19795
20255
  }
19796
- const temporary = join(directory, `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
19797
- let created = false;
19798
- let failure;
19799
- try {
19800
- await assertManagedParents(directory, temporary, "installation_id");
19801
- const handle = await open(temporary, "wx", 384);
19802
- created = true;
19803
- let writeFailure;
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;
19804
20342
  try {
19805
- await handle.writeFile(`${randomBytes(32).toString("hex")}\n`, "utf8");
19806
- } catch (error) {
19807
- writeFailure = error;
19808
- throw error;
19809
- } finally {
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 = () => {
19810
20372
  try {
19811
- await handle.close();
19812
- } catch (closeError) {
19813
- if (writeFailure !== void 0) throw new AggregateError([writeFailure, closeError], "installation ID candidate write and close failed");
19814
- throw closeError;
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;
20423
+ }
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
+ });
20445
+ }
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;
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);
20476
+ }
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;
19815
20505
  }
20506
+ this.#pending.delete(id);
20507
+ removed += 1;
19816
20508
  }
19817
- await restrictPrivateFile(temporary);
19818
- await assertManagedParents(directory, target, "installation_id");
19819
- try {
19820
- await link(temporary, target);
19821
- } catch (error) {
19822
- if (error.code !== "EEXIST") throw new Error("installation ID publication failed; the state filesystem must support hard links", { cause: error });
20509
+ for (const [id, expiresAt] of this.#sent) {
20510
+ if (expiresAt > now) continue;
20511
+ this.#sent.delete(id);
20512
+ removed += 1;
19823
20513
  }
19824
- return await readInstallationId(target);
19825
- } catch (error) {
19826
- failure = error;
19827
- throw error;
19828
- } finally {
19829
- if (created) try {
19830
- await assertManagedParents(directory, temporary, "installation_id");
19831
- await unlink(temporary);
19832
- } catch (cleanupError) {
19833
- if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
19834
- throw cleanupError;
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
+ };
20579
+ //#endregion
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
+ };
19835
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;
19836
20704
  }
19837
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
+ };
19838
20852
  //#endregion
19839
- //#region src/gateway-identity.ts
19840
- /**
19841
- * Attach the authenticated installation identity while retaining existing entry identity rules.
19842
- * @param config - Validated gateway configuration containing the legacy identity.
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.
19843
23170
  * @param installationId - Confirmed random identity read from this installation's state.
19844
23171
  * @param explicitIdentity - Whether the operator supplied the gateway identity.
19845
23172
  * @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
@@ -19939,6 +23266,31 @@ const inject = [
19939
23266
  "commands",
19940
23267
  "connection"
19941
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
+ }
19942
23294
  /** Run cleanup steps in ownership order and report every failure after all steps settle. */
19943
23295
  async function settleCleanupSteps(steps) {
19944
23296
  const errors = [];
@@ -20284,6 +23636,210 @@ async function apply(ctx, config) {
20284
23636
  logger.info("task event=%s fields=%o", event, fields);
20285
23637
  }
20286
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
+ };
20287
23843
  const remoteDirectory = join(stateDirectory, "remote");
20288
23844
  const configuredDshHome = process.env.DSH_HOME?.trim();
20289
23845
  const releaseManager = new PluginReleaseManager({ profileDirectory: releaseProfileDirectory(ctx, configuredDshHome === void 0 || configuredDshHome === "" ? dirname(stateDirectory) : resolve(configuredDshHome), process.argv.slice(2)) });
@@ -20371,7 +23927,7 @@ async function apply(ctx, config) {
20371
23927
  logger.info("upstream auth %s", JSON.stringify(entry));
20372
23928
  }, (entry) => {
20373
23929
  logger.warn("slow api %s", JSON.stringify(entry));
20374
- });
23930
+ }, pushGatewayFacade);
20375
23931
  await candidate.start();
20376
23932
  lanGateway = candidate;
20377
23933
  const removeTaskSink = taskEventHub.add(candidate);
@@ -20435,7 +23991,7 @@ async function apply(ctx, config) {
20435
23991
  logger.info("upstream auth %s", JSON.stringify(entry));
20436
23992
  }, (entry) => {
20437
23993
  logger.warn("slow api %s", JSON.stringify(entry));
20438
- });
23994
+ }, pushGatewayFacade);
20439
23995
  await candidate.start();
20440
23996
  return candidate;
20441
23997
  };
@@ -20487,7 +24043,7 @@ async function apply(ctx, config) {
20487
24043
  logger.info("upstream auth %s", JSON.stringify(entry));
20488
24044
  }, (entry) => {
20489
24045
  logger.warn("slow api %s", JSON.stringify(entry));
20490
- });
24046
+ }, pushGatewayFacade);
20491
24047
  try {
20492
24048
  await candidate.start();
20493
24049
  } catch (error) {
@@ -20506,7 +24062,7 @@ async function apply(ctx, config) {
20506
24062
  logger.info("upstream auth %s", JSON.stringify(entry));
20507
24063
  }, (entry) => {
20508
24064
  logger.warn("slow api %s", JSON.stringify(entry));
20509
- });
24065
+ }, pushGatewayFacade);
20510
24066
  try {
20511
24067
  await candidate.start();
20512
24068
  } catch (error) {
@@ -20735,9 +24291,19 @@ async function apply(ctx, config) {
20735
24291
  handler: async (request, response) => {
20736
24292
  try {
20737
24293
  const target = parseRequestTarget(request.url);
20738
- assertLocalAdminTrust(request, request.method === "POST", () => (isLoopbackAddress(request.socket.remoteAddress ?? "") || supportsRemoteAdminAuthentication) && authenticateDesktopAdmin(request), baseTemplate.adminAllowedCidrs);
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);
20739
24296
  if (adminClosing) throw new HttpError(503, "unavailable");
20740
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
+ }
20741
24307
  const lanControl = target.decodedPathname === `/api/mobile-access/control` || target.decodedPathname === `/api/mobile-access/lan/control`;
20742
24308
  if (request.method === "GET" && lanControl) {
20743
24309
  sendJson(response, 200, lanPayload(), false);
@@ -21340,7 +24906,20 @@ async function apply(ctx, config) {
21340
24906
  };
21341
24907
  }
21342
24908
  });
24909
+ let disposePushRuntimeInjection;
21343
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
+ }
21344
24923
  await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
21345
24924
  await lanController.initialize();
21346
24925
  const stores = {
@@ -21371,6 +24950,8 @@ async function apply(ctx, config) {
21371
24950
  if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
21372
24951
  },
21373
24952
  () => lanController.close(),
24953
+ () => disposePushRuntimeInjection?.(),
24954
+ closePushRuntime,
21374
24955
  () => mobileAccess.stopLocal(),
21375
24956
  unregisterBuiltin
21376
24957
  ]);
@@ -21392,6 +24973,8 @@ async function apply(ctx, config) {
21392
24973
  if (failures.length > 0) throw new AggregateError(failures, "remote provider cleanup failed");
21393
24974
  },
21394
24975
  () => lanController.close(),
24976
+ () => disposePushRuntimeInjection?.(),
24977
+ closePushRuntime,
21395
24978
  () => mobileAccess.stopLocal(),
21396
24979
  unregisterBuiltin
21397
24980
  ]);