@pellux/goodvibes-sdk 1.0.0 → 1.2.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/dist/contracts/artifacts/operator-contract.json +2878 -264
- package/dist/events/communication.d.ts +1 -1
- package/dist/platform/agents/orchestrator-runner.d.ts +5 -5
- package/dist/platform/agents/orchestrator-runner.js +6 -6
- package/dist/platform/agents/orchestrator.d.ts +4 -4
- package/dist/platform/agents/orchestrator.js +2 -2
- package/dist/platform/agents/wrfc-controller.d.ts +1 -1
- package/dist/platform/agents/wrfc-controller.js +3 -3
- package/dist/platform/calendar/index.d.ts +2 -2
- package/dist/platform/calendar/index.js +3 -3
- package/dist/platform/calendar/oauth-providers.d.ts +1 -1
- package/dist/platform/calendar/oauth-providers.js +1 -1
- package/dist/platform/calendar/oauth-types.d.ts +1 -1
- package/dist/platform/calendar/oauth-types.js +1 -1
- package/dist/platform/companion/companion-chat-branching.d.ts +66 -0
- package/dist/platform/companion/companion-chat-branching.d.ts.map +1 -0
- package/dist/platform/companion/companion-chat-branching.js +142 -0
- package/dist/platform/companion/companion-chat-broker-bridge.d.ts +1 -1
- package/dist/platform/companion/companion-chat-broker-bridge.d.ts.map +1 -1
- package/dist/platform/companion/companion-chat-broker-sync.d.ts +2 -2
- package/dist/platform/companion/companion-chat-broker-sync.d.ts.map +1 -1
- package/dist/platform/companion/companion-chat-broker-sync.js +1 -1
- package/dist/platform/companion/companion-chat-manager.d.ts +26 -5
- package/dist/platform/companion/companion-chat-manager.d.ts.map +1 -1
- package/dist/platform/companion/companion-chat-manager.js +67 -81
- package/dist/platform/companion/companion-chat-routes.d.ts.map +1 -1
- package/dist/platform/companion/companion-chat-routes.js +77 -1
- package/dist/platform/companion/companion-chat-turn-execution.d.ts +61 -0
- package/dist/platform/companion/companion-chat-turn-execution.d.ts.map +1 -0
- package/dist/platform/companion/companion-chat-turn-execution.js +107 -0
- package/dist/platform/companion/companion-chat-types.d.ts +67 -0
- package/dist/platform/companion/companion-chat-types.d.ts.map +1 -1
- package/dist/platform/config/credential-status.d.ts +1 -1
- package/dist/platform/config/credential-status.js +1 -1
- package/dist/platform/config/index.d.ts +1 -1
- package/dist/platform/config/index.js +1 -1
- package/dist/platform/config/manager.d.ts +1 -1
- package/dist/platform/config/manager.js +1 -1
- package/dist/platform/config/migrations.d.ts +2 -2
- package/dist/platform/config/migrations.js +2 -2
- package/dist/platform/config/schema-domain-runtime.d.ts +8 -0
- package/dist/platform/config/schema-domain-runtime.d.ts.map +1 -1
- package/dist/platform/config/schema-domain-runtime.js +32 -0
- package/dist/platform/config/schema-types.d.ts +10 -2
- package/dist/platform/config/schema-types.d.ts.map +1 -1
- package/dist/platform/control-plane/gateway-scope-enforcement.js +2 -2
- package/dist/platform/control-plane/index.d.ts +2 -0
- package/dist/platform/control-plane/index.d.ts.map +1 -1
- package/dist/platform/control-plane/index.js +5 -1
- package/dist/platform/control-plane/invoke-input-validation.d.ts +1 -1
- package/dist/platform/control-plane/invoke-input-validation.js +1 -1
- package/dist/platform/control-plane/method-catalog-calendar.d.ts +2 -2
- package/dist/platform/control-plane/method-catalog-calendar.js +2 -2
- package/dist/platform/control-plane/method-catalog-channels.js +3 -3
- package/dist/platform/control-plane/method-catalog-control-automation.js +4 -4
- package/dist/platform/control-plane/method-catalog-control-companion.d.ts +1 -1
- package/dist/platform/control-plane/method-catalog-control-companion.d.ts.map +1 -1
- package/dist/platform/control-plane/method-catalog-control-companion.js +43 -0
- package/dist/platform/control-plane/method-catalog-control-core.js +3 -3
- package/dist/platform/control-plane/method-catalog-email.d.ts +1 -1
- package/dist/platform/control-plane/method-catalog-email.js +1 -1
- package/dist/platform/control-plane/method-catalog-fleet.d.ts +1 -1
- package/dist/platform/control-plane/method-catalog-push.d.ts +30 -0
- package/dist/platform/control-plane/method-catalog-push.d.ts.map +1 -0
- package/dist/platform/control-plane/method-catalog-push.js +80 -0
- package/dist/platform/control-plane/method-catalog-route-reconcile.d.ts +1 -1
- package/dist/platform/control-plane/method-catalog-runtime.d.ts.map +1 -1
- package/dist/platform/control-plane/method-catalog-runtime.js +177 -1
- package/dist/platform/control-plane/method-catalog.d.ts.map +1 -1
- package/dist/platform/control-plane/method-catalog.js +2 -0
- package/dist/platform/control-plane/operator-contract-schemas-fleet.js +1 -1
- package/dist/platform/control-plane/operator-contract-schemas-runtime.d.ts +15 -0
- package/dist/platform/control-plane/operator-contract-schemas-runtime.d.ts.map +1 -1
- package/dist/platform/control-plane/operator-contract-schemas-runtime.js +81 -0
- package/dist/platform/control-plane/routes/checkpoints.d.ts +1 -1
- package/dist/platform/control-plane/routes/fleet.d.ts +2 -2
- package/dist/platform/control-plane/routes/fleet.js +1 -1
- package/dist/platform/control-plane/routes/gateway-verb-error.d.ts +1 -1
- package/dist/platform/control-plane/routes/gateway-verb-error.js +1 -1
- package/dist/platform/control-plane/routes/index.js +1 -1
- package/dist/platform/control-plane/routes/invocation-params.d.ts +1 -1
- package/dist/platform/control-plane/routes/push.d.ts +26 -0
- package/dist/platform/control-plane/routes/push.d.ts.map +1 -0
- package/dist/platform/control-plane/routes/push.js +104 -0
- package/dist/platform/control-plane/routes/register-gateway-verb-groups.d.ts +31 -0
- package/dist/platform/control-plane/routes/register-gateway-verb-groups.d.ts.map +1 -0
- package/dist/platform/control-plane/routes/register-gateway-verb-groups.js +16 -0
- package/dist/platform/control-plane/routes/register-w3-s2.d.ts +2 -2
- package/dist/platform/control-plane/routes/session-search.d.ts +4 -4
- package/dist/platform/control-plane/session-broker.d.ts +1 -1
- package/dist/platform/control-plane/session-broker.js +1 -1
- package/dist/platform/control-plane/session-store-importer.d.ts +1 -1
- package/dist/platform/control-plane/session-store-importer.js +1 -1
- package/dist/platform/control-plane/session-types.d.ts +1 -1
- package/dist/platform/core/orchestrator-runtime.d.ts +4 -4
- package/dist/platform/core/orchestrator-tool-runtime.d.ts +1 -1
- package/dist/platform/core/orchestrator-turn-loop.d.ts +6 -6
- package/dist/platform/core/orchestrator-turn-loop.d.ts.map +1 -1
- package/dist/platform/core/orchestrator-turn-loop.js +23 -22
- package/dist/platform/core/orchestrator.d.ts +10 -10
- package/dist/platform/core/orchestrator.d.ts.map +1 -1
- package/dist/platform/core/orchestrator.js +8 -8
- package/dist/platform/daemon/boot.d.ts +6 -0
- package/dist/platform/daemon/boot.d.ts.map +1 -1
- package/dist/platform/daemon/boot.js +1 -0
- package/dist/platform/daemon/control-plane.js +2 -2
- package/dist/platform/daemon/facade.d.ts +4 -7
- package/dist/platform/daemon/facade.d.ts.map +1 -1
- package/dist/platform/daemon/facade.js +5 -7
- package/dist/platform/daemon/http/router-session-broker-adapter.d.ts +1 -1
- package/dist/platform/daemon/http/router.d.ts +1 -0
- package/dist/platform/daemon/http/router.d.ts.map +1 -1
- package/dist/platform/daemon/http/router.js +36 -19
- package/dist/platform/daemon/http/runtime-route-types.d.ts +1 -1
- package/dist/platform/daemon/http/runtime-route-types.d.ts.map +1 -1
- package/dist/platform/daemon/http/webui-serving.d.ts +68 -0
- package/dist/platform/daemon/http/webui-serving.d.ts.map +1 -0
- package/dist/platform/daemon/http/webui-serving.js +230 -0
- package/dist/platform/knowledge/knowledge-api.d.ts +1 -1
- package/dist/platform/orchestration/budget.d.ts +2 -2
- package/dist/platform/orchestration/cancellation.d.ts +2 -2
- package/dist/platform/orchestration/controller-compat.d.ts +2 -3
- package/dist/platform/orchestration/controller-compat.d.ts.map +1 -1
- package/dist/platform/orchestration/dirty-guard.js +1 -1
- package/dist/platform/orchestration/engine.d.ts.map +1 -1
- package/dist/platform/orchestration/engine.js +3 -4
- package/dist/platform/orchestration/persistence.js +2 -2
- package/dist/platform/orchestration/phase-runner.d.ts +4 -4
- package/dist/platform/orchestration/scheduler.d.ts +1 -1
- package/dist/platform/orchestration/types.d.ts +3 -3
- package/dist/platform/presentation/glyphs.d.ts +1 -1
- package/dist/platform/presentation/glyphs.js +1 -1
- package/dist/platform/presentation/index.d.ts +3 -3
- package/dist/platform/presentation/index.js +3 -3
- package/dist/platform/presentation/thinking-phrases.d.ts +1 -1
- package/dist/platform/presentation/thinking-phrases.js +1 -1
- package/dist/platform/presentation/waiting-wording.d.ts +1 -1
- package/dist/platform/presentation/waiting-wording.js +1 -1
- package/dist/platform/push/delivery.d.ts +48 -0
- package/dist/platform/push/delivery.d.ts.map +1 -0
- package/dist/platform/push/delivery.js +117 -0
- package/dist/platform/push/encryption.d.ts +41 -0
- package/dist/platform/push/encryption.d.ts.map +1 -0
- package/dist/platform/push/encryption.js +82 -0
- package/dist/platform/push/index.d.ts +17 -0
- package/dist/platform/push/index.d.ts.map +1 -0
- package/dist/platform/push/index.js +10 -0
- package/dist/platform/push/service.d.ts +75 -0
- package/dist/platform/push/service.d.ts.map +1 -0
- package/dist/platform/push/service.js +95 -0
- package/dist/platform/push/subscription-store.d.ts +50 -0
- package/dist/platform/push/subscription-store.d.ts.map +1 -0
- package/dist/platform/push/subscription-store.js +123 -0
- package/dist/platform/push/types.d.ts +68 -0
- package/dist/platform/push/types.d.ts.map +1 -0
- package/dist/platform/push/types.js +13 -0
- package/dist/platform/push/vapid.d.ts +54 -0
- package/dist/platform/push/vapid.d.ts.map +1 -0
- package/dist/platform/push/vapid.js +113 -0
- package/dist/platform/runtime/feature-flags/flags.js +3 -3
- package/dist/platform/runtime/fleet/adapters/agent.d.ts +2 -2
- package/dist/platform/runtime/fleet/adapters/agent.d.ts.map +1 -1
- package/dist/platform/runtime/fleet/adapters/agent.js +4 -4
- package/dist/platform/runtime/fleet/adapters/automation.d.ts +1 -1
- package/dist/platform/runtime/fleet/adapters/automation.js +1 -1
- package/dist/platform/runtime/fleet/adapters/code-index.d.ts +1 -1
- package/dist/platform/runtime/fleet/adapters/code-index.js +1 -1
- package/dist/platform/runtime/fleet/adapters/orchestration.js +1 -1
- package/dist/platform/runtime/fleet/adapters/schedule.d.ts +4 -4
- package/dist/platform/runtime/fleet/adapters/schedule.js +4 -4
- package/dist/platform/runtime/fleet/adapters/trigger.d.ts +3 -4
- package/dist/platform/runtime/fleet/adapters/trigger.d.ts.map +1 -1
- package/dist/platform/runtime/fleet/adapters/trigger.js +3 -4
- package/dist/platform/runtime/fleet/adapters/wrfc.js +1 -1
- package/dist/platform/runtime/fleet/registry.d.ts +7 -7
- package/dist/platform/runtime/fleet/registry.d.ts.map +1 -1
- package/dist/platform/runtime/fleet/registry.js +3 -3
- package/dist/platform/runtime/fleet/types.d.ts +9 -9
- package/dist/platform/runtime/fleet/types.d.ts.map +1 -1
- package/dist/platform/runtime/memory-spine/client.d.ts +240 -0
- package/dist/platform/runtime/memory-spine/client.d.ts.map +1 -0
- package/dist/platform/runtime/memory-spine/client.js +221 -0
- package/dist/platform/runtime/memory-spine/index.d.ts +17 -0
- package/dist/platform/runtime/memory-spine/index.d.ts.map +1 -0
- package/dist/platform/runtime/memory-spine/index.js +16 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.d.ts +74 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.d.ts.map +1 -0
- package/dist/platform/runtime/memory-spine/recall-snapshot.js +74 -0
- package/dist/platform/runtime/operator-client.d.ts +1 -1
- package/dist/platform/runtime/services.d.ts +6 -6
- package/dist/platform/runtime/services.d.ts.map +1 -1
- package/dist/platform/runtime/services.js +9 -9
- package/dist/platform/runtime/session-spine/union-cache.d.ts +1 -1
- package/dist/platform/runtime/session-spine/union-cache.js +1 -1
- package/dist/platform/state/canonical-memory.d.ts +7 -7
- package/dist/platform/state/canonical-memory.js +8 -8
- package/dist/platform/state/code-index-chunking.js +1 -1
- package/dist/platform/state/code-index-db.d.ts +1 -1
- package/dist/platform/state/code-index-store.js +1 -1
- package/dist/platform/state/index.d.ts +2 -2
- package/dist/platform/state/index.d.ts.map +1 -1
- package/dist/platform/state/index.js +1 -1
- package/dist/platform/state/memory-recall-contract.d.ts +59 -4
- package/dist/platform/state/memory-recall-contract.d.ts.map +1 -1
- package/dist/platform/state/memory-recall-contract.js +79 -3
- package/dist/platform/state/memory-registry.d.ts +8 -0
- package/dist/platform/state/memory-registry.d.ts.map +1 -1
- package/dist/platform/state/memory-registry.js +10 -0
- package/dist/platform/state/memory-vector-store.js +1 -1
- package/dist/platform/state/sqlite-vec-loader.d.ts +1 -1
- package/dist/platform/state/sqlite-vec-loader.js +1 -1
- package/dist/platform/state/vibe-projection.d.ts +3 -3
- package/dist/platform/state/vibe-projection.js +3 -3
- package/dist/platform/tools/agent/manager.d.ts +10 -10
- package/dist/platform/tools/agent/manager.js +7 -7
- package/dist/platform/tools/exec/runtime.js +5 -5
- package/dist/platform/tools/exec/schema.d.ts +1 -1
- package/dist/platform/tools/exec/schema.d.ts.map +1 -1
- package/dist/platform/tools/fetch/runtime.d.ts +1 -1
- package/dist/platform/tools/registry.d.ts +1 -1
- package/dist/platform/tools/registry.js +1 -1
- package/dist/platform/types/tools.d.ts +1 -1
- package/dist/platform/utils/request-body.d.ts.map +1 -1
- package/dist/platform/utils/request-body.js +50 -5
- package/dist/platform/version.js +1 -1
- package/dist/platform/voice/index.d.ts +2 -0
- package/dist/platform/voice/index.d.ts.map +1 -1
- package/dist/platform/voice/index.js +3 -0
- package/dist/platform/voice/spoken-turn/audio-sink.d.ts +111 -0
- package/dist/platform/voice/spoken-turn/audio-sink.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/audio-sink.js +1 -0
- package/dist/platform/voice/spoken-turn/controller.d.ts +117 -0
- package/dist/platform/voice/spoken-turn/controller.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/controller.js +448 -0
- package/dist/platform/voice/spoken-turn/index.d.ts +16 -0
- package/dist/platform/voice/spoken-turn/index.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/index.js +12 -0
- package/dist/platform/voice/spoken-turn/text-chunker.d.ts +33 -0
- package/dist/platform/voice/spoken-turn/text-chunker.d.ts.map +1 -0
- package/dist/platform/voice/spoken-turn/text-chunker.js +97 -0
- package/package.json +13 -9
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/delivery.ts
|
|
3
|
+
*
|
|
4
|
+
* The single place a push message is actually encrypted and sent. Every send —
|
|
5
|
+
* a `push.subscriptions.verify` test, or an approval/completion fan-out — flows
|
|
6
|
+
* through `deliverToSubscription`, so the honesty rules live in one spot:
|
|
7
|
+
*
|
|
8
|
+
* - A push service that answers 404/410 means the browser subscription is gone;
|
|
9
|
+
* the record is pruned and the receipt says `pruned`, never a fake success.
|
|
10
|
+
* - Any other non-2xx (or a transport error) is reported as `failed` with the
|
|
11
|
+
* status/reason, never swallowed into a success.
|
|
12
|
+
* - The request carries the RFC 8188 `Content-Encoding: aes128gcm` body plus
|
|
13
|
+
* the `TTL`, `Urgency`, and VAPID `Authorization` headers a push service
|
|
14
|
+
* requires.
|
|
15
|
+
*
|
|
16
|
+
* The endpoint is whatever the browser registered. In tests that is a local
|
|
17
|
+
* HTTP sink; in production it is the browser vendor's push service. This module
|
|
18
|
+
* never contacts a hard-coded external service of its own.
|
|
19
|
+
*/
|
|
20
|
+
import { encryptPushPayload } from './encryption.js';
|
|
21
|
+
const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
|
|
22
|
+
const GONE_STATUSES = new Set([404, 410]);
|
|
23
|
+
function defaultTransport() {
|
|
24
|
+
return async (url, init) => {
|
|
25
|
+
// Re-wrap over a plain ArrayBuffer so the body matches fetch's BodyInit.
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method: init.method,
|
|
28
|
+
headers: init.headers,
|
|
29
|
+
body: new Uint8Array(init.body),
|
|
30
|
+
});
|
|
31
|
+
return { status: res.status };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function endpointOrigin(endpoint) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(endpoint).origin;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return 'invalid';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function messagePlaintext(message) {
|
|
43
|
+
return Buffer.from(JSON.stringify({ title: message.title, body: message.body, data: message.data ?? {} }), 'utf8');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Encrypt and send one message to one subscription, prune-on-gone, and return
|
|
47
|
+
* an honest receipt. The subscription's `lastOutcome` is stamped either way.
|
|
48
|
+
*/
|
|
49
|
+
export async function deliverToSubscription(subscription, message, deps) {
|
|
50
|
+
const origin = endpointOrigin(subscription.endpoint);
|
|
51
|
+
const transport = deps.transport ?? defaultTransport();
|
|
52
|
+
let httpStatus;
|
|
53
|
+
try {
|
|
54
|
+
const encrypted = encryptPushPayload(subscription.keys, messagePlaintext(message));
|
|
55
|
+
const authorization = await deps.vapid.buildAuthorizationHeader(subscription.endpoint);
|
|
56
|
+
const headers = {
|
|
57
|
+
'content-encoding': encrypted.contentEncoding,
|
|
58
|
+
'content-type': 'application/octet-stream',
|
|
59
|
+
ttl: String(message.ttlSeconds ?? DEFAULT_TTL_SECONDS),
|
|
60
|
+
urgency: message.urgency ?? 'normal',
|
|
61
|
+
authorization,
|
|
62
|
+
};
|
|
63
|
+
const result = await transport(subscription.endpoint, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers,
|
|
66
|
+
body: encrypted.body,
|
|
67
|
+
});
|
|
68
|
+
httpStatus = result.status;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await deps.store.recordOutcome(subscription.id, 'failed');
|
|
72
|
+
return {
|
|
73
|
+
subscriptionId: subscription.id,
|
|
74
|
+
endpointOrigin: origin,
|
|
75
|
+
outcome: 'failed',
|
|
76
|
+
detail: `delivery request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (httpStatus >= 200 && httpStatus < 300) {
|
|
80
|
+
await deps.store.recordOutcome(subscription.id, 'delivered');
|
|
81
|
+
return { subscriptionId: subscription.id, endpointOrigin: origin, outcome: 'delivered', httpStatus };
|
|
82
|
+
}
|
|
83
|
+
if (GONE_STATUSES.has(httpStatus)) {
|
|
84
|
+
// The subscription is gone at the push service — prune it (delete means
|
|
85
|
+
// delete) and report the prune with the status that proved it dead.
|
|
86
|
+
await deps.store.remove(subscription.id);
|
|
87
|
+
return {
|
|
88
|
+
subscriptionId: subscription.id,
|
|
89
|
+
endpointOrigin: origin,
|
|
90
|
+
outcome: 'pruned',
|
|
91
|
+
httpStatus,
|
|
92
|
+
detail: `push endpoint reported ${httpStatus} gone; subscription removed`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
await deps.store.recordOutcome(subscription.id, 'failed');
|
|
96
|
+
return {
|
|
97
|
+
subscriptionId: subscription.id,
|
|
98
|
+
endpointOrigin: origin,
|
|
99
|
+
outcome: 'failed',
|
|
100
|
+
httpStatus,
|
|
101
|
+
detail: `push service returned ${httpStatus}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fan one message out to every stored subscription, delivering (and pruning) in
|
|
106
|
+
* sequence. Returns one receipt per subscription — an empty array when there
|
|
107
|
+
* are no subscriptions at all (the honest "nobody to notify" result, not a
|
|
108
|
+
* silent success).
|
|
109
|
+
*/
|
|
110
|
+
export async function deliverToAll(message, deps) {
|
|
111
|
+
const subscriptions = await deps.store.all();
|
|
112
|
+
const receipts = [];
|
|
113
|
+
for (const subscription of subscriptions) {
|
|
114
|
+
receipts.push(await deliverToSubscription(subscription, message, deps));
|
|
115
|
+
}
|
|
116
|
+
return receipts;
|
|
117
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/encryption.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-push payload encryption, implemented with Node's built-in crypto
|
|
5
|
+
* (node:crypto) — no third-party web-push dependency. This is the daemon-side
|
|
6
|
+
* (Node/Bun) delivery path only; nothing here is imported by the runtime-neutral
|
|
7
|
+
* or browser bundles (see scripts/browser-compat-check.ts).
|
|
8
|
+
*
|
|
9
|
+
* Two standards are combined here, exactly as a browser Push service expects:
|
|
10
|
+
*
|
|
11
|
+
* - RFC 8291 (Message Encryption for Web Push): derive a shared secret from an
|
|
12
|
+
* ephemeral P-256 keypair and the subscription's public key + auth secret.
|
|
13
|
+
* - RFC 8188 (aes128gcm content encoding): expand that secret into a
|
|
14
|
+
* content-encryption key + nonce and encrypt one record, then frame it with
|
|
15
|
+
* the salt / record-size / sender-public-key header the receiver reads back.
|
|
16
|
+
*
|
|
17
|
+
* The output Buffer is the raw request body sent to the subscription endpoint
|
|
18
|
+
* with `Content-Encoding: aes128gcm`.
|
|
19
|
+
*/
|
|
20
|
+
/** The subscription's own key material, base64url-encoded (browser PushSubscription shape). */
|
|
21
|
+
export interface SubscriptionKeyMaterial {
|
|
22
|
+
/** The receiver's public key — 65-byte uncompressed P-256 point, base64url. */
|
|
23
|
+
readonly p256dh: string;
|
|
24
|
+
/** The receiver's 16-byte authentication secret, base64url. */
|
|
25
|
+
readonly auth: string;
|
|
26
|
+
}
|
|
27
|
+
export interface EncryptedPushPayload {
|
|
28
|
+
/** The aes128gcm request body: header || ciphertext || GCM tag. */
|
|
29
|
+
readonly body: Buffer;
|
|
30
|
+
/** Always `aes128gcm` — the value for the Content-Encoding request header. */
|
|
31
|
+
readonly contentEncoding: 'aes128gcm';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Encrypt `plaintext` for a subscription's key material.
|
|
35
|
+
*
|
|
36
|
+
* A fresh ephemeral sender keypair and salt are generated per call (RFC 8291
|
|
37
|
+
* requires this — the same salt/key pair must never encrypt two messages), so
|
|
38
|
+
* the result is non-deterministic by design.
|
|
39
|
+
*/
|
|
40
|
+
export declare function encryptPushPayload(keys: SubscriptionKeyMaterial, plaintext: Buffer): EncryptedPushPayload;
|
|
41
|
+
//# sourceMappingURL=encryption.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encryption.d.ts","sourceRoot":"","sources":["../../../src/platform/push/encryption.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,+FAA+F;AAC/F,MAAM,WAAW,uBAAuB;IACtC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,eAAe,EAAE,WAAW,CAAC;CACvC;AAyBD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,uBAAuB,EAC7B,SAAS,EAAE,MAAM,GAChB,oBAAoB,CA2CtB"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/encryption.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-push payload encryption, implemented with Node's built-in crypto
|
|
5
|
+
* (node:crypto) — no third-party web-push dependency. This is the daemon-side
|
|
6
|
+
* (Node/Bun) delivery path only; nothing here is imported by the runtime-neutral
|
|
7
|
+
* or browser bundles (see scripts/browser-compat-check.ts).
|
|
8
|
+
*
|
|
9
|
+
* Two standards are combined here, exactly as a browser Push service expects:
|
|
10
|
+
*
|
|
11
|
+
* - RFC 8291 (Message Encryption for Web Push): derive a shared secret from an
|
|
12
|
+
* ephemeral P-256 keypair and the subscription's public key + auth secret.
|
|
13
|
+
* - RFC 8188 (aes128gcm content encoding): expand that secret into a
|
|
14
|
+
* content-encryption key + nonce and encrypt one record, then frame it with
|
|
15
|
+
* the salt / record-size / sender-public-key header the receiver reads back.
|
|
16
|
+
*
|
|
17
|
+
* The output Buffer is the raw request body sent to the subscription endpoint
|
|
18
|
+
* with `Content-Encoding: aes128gcm`.
|
|
19
|
+
*/
|
|
20
|
+
import { createCipheriv, createECDH, createHmac, randomBytes } from 'node:crypto';
|
|
21
|
+
/**
|
|
22
|
+
* Single fixed record size. Push payloads here (an approval summary, a
|
|
23
|
+
* completion note) are far below this, so one aes128gcm record always
|
|
24
|
+
* suffices; a payload that would not fit is rejected honestly rather than
|
|
25
|
+
* silently truncated or split.
|
|
26
|
+
*/
|
|
27
|
+
const RECORD_SIZE = 4096;
|
|
28
|
+
const KEY_INFO_PREFIX = Buffer.from('WebPush: info\0', 'utf8');
|
|
29
|
+
const CEK_INFO = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
|
30
|
+
const NONCE_INFO = Buffer.from('Content-Encoding: nonce\0', 'utf8');
|
|
31
|
+
/** HKDF (RFC 5869) specialized to a single output block (length <= 32). */
|
|
32
|
+
function hkdf(salt, ikm, info, length) {
|
|
33
|
+
const prk = createHmac('sha256', salt).update(ikm).digest();
|
|
34
|
+
const okm = createHmac('sha256', prk).update(Buffer.concat([info, Buffer.from([0x01])])).digest();
|
|
35
|
+
return okm.subarray(0, length);
|
|
36
|
+
}
|
|
37
|
+
function base64UrlToBuffer(value) {
|
|
38
|
+
return Buffer.from(value, 'base64url');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Encrypt `plaintext` for a subscription's key material.
|
|
42
|
+
*
|
|
43
|
+
* A fresh ephemeral sender keypair and salt are generated per call (RFC 8291
|
|
44
|
+
* requires this — the same salt/key pair must never encrypt two messages), so
|
|
45
|
+
* the result is non-deterministic by design.
|
|
46
|
+
*/
|
|
47
|
+
export function encryptPushPayload(keys, plaintext) {
|
|
48
|
+
const receiverPublic = base64UrlToBuffer(keys.p256dh);
|
|
49
|
+
const authSecret = base64UrlToBuffer(keys.auth);
|
|
50
|
+
if (receiverPublic.length !== 65) {
|
|
51
|
+
throw new Error('Push subscription p256dh key is not a 65-byte uncompressed P-256 point');
|
|
52
|
+
}
|
|
53
|
+
if (authSecret.length !== 16) {
|
|
54
|
+
throw new Error('Push subscription auth secret is not 16 bytes');
|
|
55
|
+
}
|
|
56
|
+
// Ephemeral sender (application-server) keypair for this one message.
|
|
57
|
+
const sender = createECDH('prime256v1');
|
|
58
|
+
sender.generateKeys();
|
|
59
|
+
const senderPublic = sender.getPublicKey();
|
|
60
|
+
const sharedSecret = sender.computeSecret(receiverPublic);
|
|
61
|
+
const salt = randomBytes(16);
|
|
62
|
+
// RFC 8291: mix the shared secret with the auth secret and both public keys.
|
|
63
|
+
const keyInfo = Buffer.concat([KEY_INFO_PREFIX, receiverPublic, senderPublic]);
|
|
64
|
+
const ikm = hkdf(authSecret, sharedSecret, keyInfo, 32);
|
|
65
|
+
// RFC 8188: expand into the content-encryption key and nonce.
|
|
66
|
+
const contentEncryptionKey = hkdf(salt, ikm, CEK_INFO, 16);
|
|
67
|
+
const nonce = hkdf(salt, ikm, NONCE_INFO, 12);
|
|
68
|
+
// One record: plaintext followed by the 0x02 last-record delimiter.
|
|
69
|
+
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
|
70
|
+
if (record.length + 16 > RECORD_SIZE) {
|
|
71
|
+
throw new Error(`Push payload too large for a single aes128gcm record (max ${RECORD_SIZE - 17} bytes)`);
|
|
72
|
+
}
|
|
73
|
+
const cipher = createCipheriv('aes-128-gcm', contentEncryptionKey, nonce);
|
|
74
|
+
const ciphertext = Buffer.concat([cipher.update(record), cipher.final(), cipher.getAuthTag()]);
|
|
75
|
+
// aes128gcm header: salt(16) | record-size(4, big-endian) | keyid-len(1) | keyid(=senderPublic).
|
|
76
|
+
const header = Buffer.alloc(16 + 4 + 1 + senderPublic.length);
|
|
77
|
+
salt.copy(header, 0);
|
|
78
|
+
header.writeUInt32BE(RECORD_SIZE, 16);
|
|
79
|
+
header.writeUInt8(senderPublic.length, 20);
|
|
80
|
+
senderPublic.copy(header, 21);
|
|
81
|
+
return { body: Buffer.concat([header, ciphertext]), contentEncoding: 'aes128gcm' };
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/index.ts — the browser-push module barrel (VAPID custody, subscription
|
|
3
|
+
* store, RFC 8291 encryption, and the delivery path). Daemon-side only; not
|
|
4
|
+
* part of the runtime-neutral or browser bundles.
|
|
5
|
+
*/
|
|
6
|
+
export { encryptPushPayload } from './encryption.js';
|
|
7
|
+
export type { SubscriptionKeyMaterial, EncryptedPushPayload } from './encryption.js';
|
|
8
|
+
export { VapidManager, VAPID_SECRET_KEY } from './vapid.js';
|
|
9
|
+
export type { VapidSecretStore, VapidManagerOptions } from './vapid.js';
|
|
10
|
+
export { PushSubscriptionStore, toPublicSubscription } from './subscription-store.js';
|
|
11
|
+
export type { RegisterSubscriptionInput } from './subscription-store.js';
|
|
12
|
+
export { deliverToSubscription, deliverToAll } from './delivery.js';
|
|
13
|
+
export type { PushTransport, DeliveryDeps } from './delivery.js';
|
|
14
|
+
export { PushService } from './service.js';
|
|
15
|
+
export type { PushServiceDeps, SubscribeInput, ApprovalSource, ApprovalNotice } from './service.js';
|
|
16
|
+
export type { StoredPushSubscription, PublicPushSubscription, PushDeliveryOutcome, PushDeliveryReceipt, PushUrgency, PushMessage, } from './types.js';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/platform/push/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC5D,YAAY,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AACtF,YAAY,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACpE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACpG,YAAY,EACV,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,WAAW,EACX,WAAW,GACZ,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/index.ts — the browser-push module barrel (VAPID custody, subscription
|
|
3
|
+
* store, RFC 8291 encryption, and the delivery path). Daemon-side only; not
|
|
4
|
+
* part of the runtime-neutral or browser bundles.
|
|
5
|
+
*/
|
|
6
|
+
export { encryptPushPayload } from './encryption.js';
|
|
7
|
+
export { VapidManager, VAPID_SECRET_KEY } from './vapid.js';
|
|
8
|
+
export { PushSubscriptionStore, toPublicSubscription } from './subscription-store.js';
|
|
9
|
+
export { deliverToSubscription, deliverToAll } from './delivery.js';
|
|
10
|
+
export { PushService } from './service.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/service.ts
|
|
3
|
+
*
|
|
4
|
+
* PushService — the seam the gateway verbs and the daemon event sources both
|
|
5
|
+
* talk to. It owns the subscription store, the VAPID key custody, and the
|
|
6
|
+
* delivery path, and it turns a real daemon event (an approval that needs a
|
|
7
|
+
* decision) into a fan-out of encrypted pushes.
|
|
8
|
+
*
|
|
9
|
+
* Honest-degrade posture, end to end:
|
|
10
|
+
* - No subscriptions -> `deliver()` returns an empty receipt list; nothing is
|
|
11
|
+
* faked as sent.
|
|
12
|
+
* - A subscription whose endpoint is gone (404/410) is pruned by the delivery
|
|
13
|
+
* path and reported as `pruned`.
|
|
14
|
+
* - VAPID keys are minted lazily on first real need; a daemon that never uses
|
|
15
|
+
* push never generates or stores a key.
|
|
16
|
+
*/
|
|
17
|
+
import { type PushTransport } from './delivery.js';
|
|
18
|
+
import { PushSubscriptionStore } from './subscription-store.js';
|
|
19
|
+
import { VapidManager } from './vapid.js';
|
|
20
|
+
import type { PublicPushSubscription, PushDeliveryReceipt, PushMessage, SubscriptionKeyMaterial } from './types.js';
|
|
21
|
+
/** The slice of the approval broker the push delivery source subscribes to. */
|
|
22
|
+
export interface ApprovalSource {
|
|
23
|
+
subscribe(listener: (approval: ApprovalNotice) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
/** The minimum an approval record needs to expose to become a push. */
|
|
26
|
+
export interface ApprovalNotice {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly status: string;
|
|
29
|
+
readonly request?: {
|
|
30
|
+
readonly tool?: string;
|
|
31
|
+
readonly analysis?: {
|
|
32
|
+
readonly summary?: string;
|
|
33
|
+
};
|
|
34
|
+
} | undefined;
|
|
35
|
+
}
|
|
36
|
+
export interface PushServiceDeps {
|
|
37
|
+
readonly vapid: VapidManager;
|
|
38
|
+
readonly store: PushSubscriptionStore;
|
|
39
|
+
/** Overridable delivery transport; production uses the built-in fetch. */
|
|
40
|
+
readonly transport?: PushTransport | undefined;
|
|
41
|
+
}
|
|
42
|
+
export interface SubscribeInput {
|
|
43
|
+
readonly principalId: string;
|
|
44
|
+
readonly endpoint: string;
|
|
45
|
+
readonly keys: SubscriptionKeyMaterial;
|
|
46
|
+
}
|
|
47
|
+
export declare class PushService {
|
|
48
|
+
private readonly vapid;
|
|
49
|
+
private readonly store;
|
|
50
|
+
private readonly transport?;
|
|
51
|
+
/** Approval ids already pushed, so re-publishes (claim/approve) don't re-notify. */
|
|
52
|
+
private readonly notifiedApprovals;
|
|
53
|
+
constructor(deps: PushServiceDeps);
|
|
54
|
+
/** The public VAPID key clients subscribe with. */
|
|
55
|
+
getPublicKey(): Promise<string>;
|
|
56
|
+
subscribe(input: SubscribeInput): Promise<PublicPushSubscription>;
|
|
57
|
+
listSubscriptions(principalId: string): Promise<PublicPushSubscription[]>;
|
|
58
|
+
/** Delete a subscription for a principal. False when the id was already absent. */
|
|
59
|
+
unsubscribe(id: string, principalId: string): Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* Send a test push to one of the principal's subscriptions and return the
|
|
62
|
+
* honest receipt (delivered / pruned / failed). Returns null when the id is
|
|
63
|
+
* not a subscription owned by this principal, so the verb can 404.
|
|
64
|
+
*/
|
|
65
|
+
verify(id: string, principalId: string): Promise<PushDeliveryReceipt | null>;
|
|
66
|
+
/** Fan a message out to every stored subscription. */
|
|
67
|
+
deliver(message: PushMessage): Promise<PushDeliveryReceipt[]>;
|
|
68
|
+
/**
|
|
69
|
+
* Wire a real event source: when an approval is created (status `pending`),
|
|
70
|
+
* push it to every registered device. Later re-publishes of the same approval
|
|
71
|
+
* (claimed/approved/denied) do not re-notify. Returns an unsubscribe handle.
|
|
72
|
+
*/
|
|
73
|
+
attachApprovalSource(source: ApprovalSource): () => void;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/platform/push/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EAAuC,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AACxF,OAAO,EAAE,qBAAqB,EAAwB,MAAM,yBAAyB,CAAC;AACtF,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,KAAK,EACV,sBAAsB,EACtB,mBAAmB,EACnB,WAAW,EACX,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAEpB,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACrE;AAED,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE;YAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,SAAS,CAAC;CAC9G;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,qBAAqB,CAAC;IACtC,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;CAChD;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;CACxC;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwB;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAA4B;IACvD,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqB;gBAE3C,IAAI,EAAE,eAAe;IAMjC,mDAAmD;IACnD,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC;IAIzB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAKvE,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC;IAIzE,mFAAmF;IACnF,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI9D;;;;OAIG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAclF,sDAAsD;IACtD,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAI7D;;;;OAIG;IACH,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,IAAI;CAsBzD"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/service.ts
|
|
3
|
+
*
|
|
4
|
+
* PushService — the seam the gateway verbs and the daemon event sources both
|
|
5
|
+
* talk to. It owns the subscription store, the VAPID key custody, and the
|
|
6
|
+
* delivery path, and it turns a real daemon event (an approval that needs a
|
|
7
|
+
* decision) into a fan-out of encrypted pushes.
|
|
8
|
+
*
|
|
9
|
+
* Honest-degrade posture, end to end:
|
|
10
|
+
* - No subscriptions -> `deliver()` returns an empty receipt list; nothing is
|
|
11
|
+
* faked as sent.
|
|
12
|
+
* - A subscription whose endpoint is gone (404/410) is pruned by the delivery
|
|
13
|
+
* path and reported as `pruned`.
|
|
14
|
+
* - VAPID keys are minted lazily on first real need; a daemon that never uses
|
|
15
|
+
* push never generates or stores a key.
|
|
16
|
+
*/
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
import { deliverToAll, deliverToSubscription } from './delivery.js';
|
|
19
|
+
import { PushSubscriptionStore, toPublicSubscription } from './subscription-store.js';
|
|
20
|
+
import { VapidManager } from './vapid.js';
|
|
21
|
+
export class PushService {
|
|
22
|
+
vapid;
|
|
23
|
+
store;
|
|
24
|
+
transport;
|
|
25
|
+
/** Approval ids already pushed, so re-publishes (claim/approve) don't re-notify. */
|
|
26
|
+
notifiedApprovals = new Set();
|
|
27
|
+
constructor(deps) {
|
|
28
|
+
this.vapid = deps.vapid;
|
|
29
|
+
this.store = deps.store;
|
|
30
|
+
this.transport = deps.transport;
|
|
31
|
+
}
|
|
32
|
+
/** The public VAPID key clients subscribe with. */
|
|
33
|
+
getPublicKey() {
|
|
34
|
+
return this.vapid.getPublicKey();
|
|
35
|
+
}
|
|
36
|
+
async subscribe(input) {
|
|
37
|
+
const record = await this.store.register(input);
|
|
38
|
+
return toPublicSubscription(record);
|
|
39
|
+
}
|
|
40
|
+
listSubscriptions(principalId) {
|
|
41
|
+
return this.store.listPublic(principalId);
|
|
42
|
+
}
|
|
43
|
+
/** Delete a subscription for a principal. False when the id was already absent. */
|
|
44
|
+
unsubscribe(id, principalId) {
|
|
45
|
+
return this.store.remove(id, principalId);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Send a test push to one of the principal's subscriptions and return the
|
|
49
|
+
* honest receipt (delivered / pruned / failed). Returns null when the id is
|
|
50
|
+
* not a subscription owned by this principal, so the verb can 404.
|
|
51
|
+
*/
|
|
52
|
+
async verify(id, principalId) {
|
|
53
|
+
const record = await this.store.get(id);
|
|
54
|
+
if (!record || record.principalId !== principalId)
|
|
55
|
+
return null;
|
|
56
|
+
return deliverToSubscription(record, {
|
|
57
|
+
title: 'GoodVibes test notification',
|
|
58
|
+
body: 'Browser push is wired up and working.',
|
|
59
|
+
urgency: 'normal',
|
|
60
|
+
}, { vapid: this.vapid, store: this.store, transport: this.transport });
|
|
61
|
+
}
|
|
62
|
+
/** Fan a message out to every stored subscription. */
|
|
63
|
+
deliver(message) {
|
|
64
|
+
return deliverToAll(message, { vapid: this.vapid, store: this.store, transport: this.transport });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Wire a real event source: when an approval is created (status `pending`),
|
|
68
|
+
* push it to every registered device. Later re-publishes of the same approval
|
|
69
|
+
* (claimed/approved/denied) do not re-notify. Returns an unsubscribe handle.
|
|
70
|
+
*/
|
|
71
|
+
attachApprovalSource(source) {
|
|
72
|
+
return source.subscribe((approval) => {
|
|
73
|
+
if (approval.status !== 'pending')
|
|
74
|
+
return;
|
|
75
|
+
if (this.notifiedApprovals.has(approval.id))
|
|
76
|
+
return;
|
|
77
|
+
this.notifiedApprovals.add(approval.id);
|
|
78
|
+
const tool = approval.request?.tool ?? 'a tool';
|
|
79
|
+
const summary = approval.request?.analysis?.summary ?? 'A pending action needs your decision.';
|
|
80
|
+
// Fire-and-forget: an approval must never block on a push, and a delivery
|
|
81
|
+
// failure is already captured as an honest receipt/outcome inside deliver().
|
|
82
|
+
void this.deliver({
|
|
83
|
+
title: 'Approval required',
|
|
84
|
+
body: `${tool}: ${summary}`,
|
|
85
|
+
data: { kind: 'approval', approvalId: approval.id },
|
|
86
|
+
urgency: 'high',
|
|
87
|
+
}).catch((error) => {
|
|
88
|
+
logger.warn('PushService: approval fan-out failed', {
|
|
89
|
+
approvalId: approval.id,
|
|
90
|
+
error: error instanceof Error ? error.message : String(error),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/subscription-store.ts
|
|
3
|
+
*
|
|
4
|
+
* The on-disk record of which devices an operator has registered for browser
|
|
5
|
+
* push. Persisted with the same atomic-JSON `PersistentStore` the approval and
|
|
6
|
+
* session stores use — the capability URLs and key material stay on disk in the
|
|
7
|
+
* daemon's own state directory, never on the wire.
|
|
8
|
+
*
|
|
9
|
+
* Delete means delete: `remove()` drops the record entirely (it does not flag a
|
|
10
|
+
* tombstone), and `list()` cannot return it afterward. Pruning a dead endpoint
|
|
11
|
+
* uses the same `remove()` path.
|
|
12
|
+
*/
|
|
13
|
+
import type { PublicPushSubscription, StoredPushSubscription, SubscriptionKeyMaterial } from './types.js';
|
|
14
|
+
export interface RegisterSubscriptionInput {
|
|
15
|
+
readonly principalId: string;
|
|
16
|
+
readonly endpoint: string;
|
|
17
|
+
readonly keys: SubscriptionKeyMaterial;
|
|
18
|
+
}
|
|
19
|
+
/** The redacted, wire-safe projection of a stored subscription. */
|
|
20
|
+
export declare function toPublicSubscription(record: StoredPushSubscription): PublicPushSubscription;
|
|
21
|
+
export declare class PushSubscriptionStore {
|
|
22
|
+
private readonly store;
|
|
23
|
+
private records;
|
|
24
|
+
constructor(filePath: string);
|
|
25
|
+
private ensureLoaded;
|
|
26
|
+
private flush;
|
|
27
|
+
/**
|
|
28
|
+
* Register (or refresh) a subscription. Two subscriptions from the same
|
|
29
|
+
* principal for the same endpoint collapse onto one record — a browser that
|
|
30
|
+
* re-subscribes with rotated keys updates in place rather than piling up
|
|
31
|
+
* duplicate capability URLs.
|
|
32
|
+
*/
|
|
33
|
+
register(input: RegisterSubscriptionInput): Promise<StoredPushSubscription>;
|
|
34
|
+
/** All subscriptions for a principal, redacted for the wire. */
|
|
35
|
+
listPublic(principalId: string): Promise<PublicPushSubscription[]>;
|
|
36
|
+
/** The full record (endpoint + keys) for a delivery. Not for the wire. */
|
|
37
|
+
get(id: string): Promise<StoredPushSubscription | null>;
|
|
38
|
+
/** Every stored subscription — the delivery fan-out reads this. Not for the wire. */
|
|
39
|
+
all(): Promise<readonly StoredPushSubscription[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Delete a subscription. Returns true if a record was actually removed, false
|
|
42
|
+
* if the id was already absent — the caller reports an honest 404 rather than
|
|
43
|
+
* a 200-noop. An optional `principalId` scopes the delete so one operator
|
|
44
|
+
* cannot remove another's device.
|
|
45
|
+
*/
|
|
46
|
+
remove(id: string, principalId?: string): Promise<boolean>;
|
|
47
|
+
/** Record the outcome of the last delivery attempt against a subscription. */
|
|
48
|
+
recordOutcome(id: string, outcome: StoredPushSubscription['lastOutcome']): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=subscription-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscription-store.d.ts","sourceRoot":"","sources":["../../../src/platform/push/subscription-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EACV,sBAAsB,EACtB,sBAAsB,EACtB,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAMpB,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;CACxC;AAcD,mEAAmE;AACnE,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,sBAAsB,GAAG,sBAAsB,CAU3F;AAED,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwC;IAC9D,OAAO,CAAC,OAAO,CAAyC;gBAE5C,QAAQ,EAAE,MAAM;YAId,YAAY;YAOZ,KAAK;IAInB;;;;;OAKG;IACG,QAAQ,CAAC,KAAK,EAAE,yBAAyB,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAyBjF,gEAAgE;IAC1D,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC;IAOxE,0EAA0E;IACpE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC;IAK7D,qFAAqF;IAC/E,GAAG,IAAI,OAAO,CAAC,SAAS,sBAAsB,EAAE,CAAC;IAIvD;;;;;OAKG;IACG,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWhE,8EAA8E;IACxE,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB,CAAC,aAAa,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAQ/F"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push/subscription-store.ts
|
|
3
|
+
*
|
|
4
|
+
* The on-disk record of which devices an operator has registered for browser
|
|
5
|
+
* push. Persisted with the same atomic-JSON `PersistentStore` the approval and
|
|
6
|
+
* session stores use — the capability URLs and key material stay on disk in the
|
|
7
|
+
* daemon's own state directory, never on the wire.
|
|
8
|
+
*
|
|
9
|
+
* Delete means delete: `remove()` drops the record entirely (it does not flag a
|
|
10
|
+
* tombstone), and `list()` cannot return it afterward. Pruning a dead endpoint
|
|
11
|
+
* uses the same `remove()` path.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
14
|
+
import { PersistentStore } from '../state/persistent-store.js';
|
|
15
|
+
function endpointOrigin(endpoint) {
|
|
16
|
+
try {
|
|
17
|
+
return new URL(endpoint).origin;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 'invalid';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function endpointHash(endpoint) {
|
|
24
|
+
return createHash('sha256').update(endpoint).digest('base64url').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
/** The redacted, wire-safe projection of a stored subscription. */
|
|
27
|
+
export function toPublicSubscription(record) {
|
|
28
|
+
return {
|
|
29
|
+
id: record.id,
|
|
30
|
+
principalId: record.principalId,
|
|
31
|
+
endpointOrigin: endpointOrigin(record.endpoint),
|
|
32
|
+
endpointHash: endpointHash(record.endpoint),
|
|
33
|
+
createdAt: record.createdAt,
|
|
34
|
+
lastDeliveryAt: record.lastDeliveryAt,
|
|
35
|
+
lastOutcome: record.lastOutcome,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export class PushSubscriptionStore {
|
|
39
|
+
store;
|
|
40
|
+
records = null;
|
|
41
|
+
constructor(filePath) {
|
|
42
|
+
this.store = new PersistentStore(filePath);
|
|
43
|
+
}
|
|
44
|
+
async ensureLoaded() {
|
|
45
|
+
if (this.records)
|
|
46
|
+
return this.records;
|
|
47
|
+
const snapshot = await this.store.load();
|
|
48
|
+
this.records = snapshot?.subscriptions ? [...snapshot.subscriptions] : [];
|
|
49
|
+
return this.records;
|
|
50
|
+
}
|
|
51
|
+
async flush() {
|
|
52
|
+
await this.store.persist({ subscriptions: this.records ?? [] });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Register (or refresh) a subscription. Two subscriptions from the same
|
|
56
|
+
* principal for the same endpoint collapse onto one record — a browser that
|
|
57
|
+
* re-subscribes with rotated keys updates in place rather than piling up
|
|
58
|
+
* duplicate capability URLs.
|
|
59
|
+
*/
|
|
60
|
+
async register(input) {
|
|
61
|
+
const records = await this.ensureLoaded();
|
|
62
|
+
const existingIndex = records.findIndex((r) => r.principalId === input.principalId && r.endpoint === input.endpoint);
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (existingIndex >= 0) {
|
|
65
|
+
const prior = records[existingIndex];
|
|
66
|
+
const updated = { ...prior, keys: input.keys };
|
|
67
|
+
records[existingIndex] = updated;
|
|
68
|
+
await this.flush();
|
|
69
|
+
return updated;
|
|
70
|
+
}
|
|
71
|
+
const record = {
|
|
72
|
+
id: `push-${randomUUID()}`,
|
|
73
|
+
principalId: input.principalId,
|
|
74
|
+
endpoint: input.endpoint,
|
|
75
|
+
keys: input.keys,
|
|
76
|
+
createdAt: now,
|
|
77
|
+
};
|
|
78
|
+
records.push(record);
|
|
79
|
+
await this.flush();
|
|
80
|
+
return record;
|
|
81
|
+
}
|
|
82
|
+
/** All subscriptions for a principal, redacted for the wire. */
|
|
83
|
+
async listPublic(principalId) {
|
|
84
|
+
const records = await this.ensureLoaded();
|
|
85
|
+
return records
|
|
86
|
+
.filter((r) => r.principalId === principalId)
|
|
87
|
+
.map(toPublicSubscription);
|
|
88
|
+
}
|
|
89
|
+
/** The full record (endpoint + keys) for a delivery. Not for the wire. */
|
|
90
|
+
async get(id) {
|
|
91
|
+
const records = await this.ensureLoaded();
|
|
92
|
+
return records.find((r) => r.id === id) ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** Every stored subscription — the delivery fan-out reads this. Not for the wire. */
|
|
95
|
+
async all() {
|
|
96
|
+
return [...(await this.ensureLoaded())];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Delete a subscription. Returns true if a record was actually removed, false
|
|
100
|
+
* if the id was already absent — the caller reports an honest 404 rather than
|
|
101
|
+
* a 200-noop. An optional `principalId` scopes the delete so one operator
|
|
102
|
+
* cannot remove another's device.
|
|
103
|
+
*/
|
|
104
|
+
async remove(id, principalId) {
|
|
105
|
+
const records = await this.ensureLoaded();
|
|
106
|
+
const index = records.findIndex((r) => r.id === id && (principalId === undefined || r.principalId === principalId));
|
|
107
|
+
if (index < 0)
|
|
108
|
+
return false;
|
|
109
|
+
records.splice(index, 1);
|
|
110
|
+
await this.flush();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** Record the outcome of the last delivery attempt against a subscription. */
|
|
114
|
+
async recordOutcome(id, outcome) {
|
|
115
|
+
const records = await this.ensureLoaded();
|
|
116
|
+
const index = records.findIndex((r) => r.id === id);
|
|
117
|
+
if (index < 0)
|
|
118
|
+
return;
|
|
119
|
+
const prior = records[index];
|
|
120
|
+
records[index] = { ...prior, lastDeliveryAt: Date.now(), lastOutcome: outcome };
|
|
121
|
+
await this.flush();
|
|
122
|
+
}
|
|
123
|
+
}
|