okengine 0.6.0 → 0.7.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/AGENTS.md +2 -2
- package/README.md +98 -71
- package/package.json +4 -4
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +90 -20
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +18 -17
- package/site/content/docs/reference/environment-variables.mdx +44 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +7 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/index.ts +9 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `webpush` channel driver — RFC 8030
|
|
2
|
+
* `webpush` channel driver — RFC 8030 + VAPID via sently.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Call path: ChannelTransport.send → createPushSender → WebPushTransport.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createPushSender } from "sently/push";
|
|
8
|
+
import { WebPushTransport } from "sently/transports/webpush";
|
|
9
|
+
import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
|
|
7
10
|
import type {
|
|
8
11
|
ChannelDriver,
|
|
9
12
|
ChannelMessage,
|
|
@@ -12,263 +15,60 @@ import type {
|
|
|
12
15
|
ChannelTransport,
|
|
13
16
|
} from "./channel-types.ts";
|
|
14
17
|
|
|
15
|
-
/**
|
|
16
|
-
* Decode a URL-safe base64 string.
|
|
17
|
-
*
|
|
18
|
-
* @param input - Base64url text
|
|
19
|
-
*/
|
|
20
|
-
function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
|
|
21
|
-
const pad = "=".repeat((4 - (input.length % 4)) % 4);
|
|
22
|
-
const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
23
|
-
const bin = atob(b64);
|
|
24
|
-
const out = new Uint8Array(bin.length);
|
|
25
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
26
|
-
return out;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Fresh buffer for Web Crypto `BufferSource` typing. */
|
|
30
|
-
function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
31
|
-
return new Uint8Array(bytes);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Encode bytes as base64url (no padding).
|
|
36
|
-
*
|
|
37
|
-
* @param bytes - Input
|
|
38
|
-
*/
|
|
39
|
-
function b64urlEncode(bytes: Uint8Array): string {
|
|
40
|
-
let s = "";
|
|
41
|
-
for (const b of bytes) s += String.fromCharCode(b);
|
|
42
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Import a VAPID ECDSA P-256 private key from base64url raw or PKCS8.
|
|
47
|
-
*
|
|
48
|
-
* @param privateKeyB64 - Base64url private key
|
|
49
|
-
*/
|
|
50
|
-
async function importVapidPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
|
|
51
|
-
const raw = b64urlDecode(privateKeyB64);
|
|
52
|
-
// Prefer PKCS8; fall back to raw JWK construction for 32-byte seeds.
|
|
53
|
-
if (raw.length > 32) {
|
|
54
|
-
return crypto.subtle.importKey(
|
|
55
|
-
"pkcs8",
|
|
56
|
-
asBufferSource(raw),
|
|
57
|
-
{ name: "ECDSA", namedCurve: "P-256" },
|
|
58
|
-
false,
|
|
59
|
-
["sign"],
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
// Build a minimal JWK from d (and a synthetic public x/y via derive is hard);
|
|
63
|
-
// tests inject fetch and only need Authorization header shape — use ECDSA
|
|
64
|
-
// with a generated key when raw seed is provided without full PKCS8.
|
|
65
|
-
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
66
|
-
"sign",
|
|
67
|
-
"verify",
|
|
68
|
-
]);
|
|
69
|
-
return pair.privateKey;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Build a VAPID JWT (RFC 8292) for the push audience.
|
|
74
|
-
*
|
|
75
|
-
* @param audience - Origin of the push service
|
|
76
|
-
* @param subject - `mailto:` or HTTPS contact
|
|
77
|
-
* @param privateKey - ECDSA P-256 key
|
|
78
|
-
*/
|
|
79
|
-
async function buildVapidJwt(
|
|
80
|
-
audience: string,
|
|
81
|
-
subject: string,
|
|
82
|
-
privateKey: CryptoKey,
|
|
83
|
-
): Promise<string> {
|
|
84
|
-
const header = b64urlEncode(
|
|
85
|
-
new TextEncoder().encode(JSON.stringify({ typ: "JWT", alg: "ES256" })),
|
|
86
|
-
);
|
|
87
|
-
const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60;
|
|
88
|
-
const payload = b64urlEncode(
|
|
89
|
-
new TextEncoder().encode(JSON.stringify({ aud: audience, exp, sub: subject })),
|
|
90
|
-
);
|
|
91
|
-
const data = new TextEncoder().encode(`${header}.${payload}`);
|
|
92
|
-
const sig = new Uint8Array(
|
|
93
|
-
await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data),
|
|
94
|
-
);
|
|
95
|
-
// Convert DER → raw r||s if needed; Web Crypto in Bun returns raw P-1363.
|
|
96
|
-
return `${header}.${payload}.${b64urlEncode(sig)}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Minimal RFC 8030 encrypted body: salt + rs + server public + ciphertext.
|
|
101
|
-
* For tests with injected fetch we send a well-formed envelope; production
|
|
102
|
-
* should use full ece (aes128gcm). Here we implement aes128gcm content-coding
|
|
103
|
-
* with Web Crypto ECDH + HKDF as specified.
|
|
104
|
-
*
|
|
105
|
-
* @param plaintext - Push payload bytes
|
|
106
|
-
* @param p256dh - Subscriber public key (base64url)
|
|
107
|
-
* @param auth - Subscriber auth secret (base64url)
|
|
108
|
-
*/
|
|
109
|
-
async function encryptPushBody(
|
|
110
|
-
plaintext: Uint8Array,
|
|
111
|
-
p256dh: string,
|
|
112
|
-
auth: string,
|
|
113
|
-
): Promise<{ body: Uint8Array; salt: Uint8Array; localPublic: Uint8Array }> {
|
|
114
|
-
const userPublic = await crypto.subtle.importKey(
|
|
115
|
-
"raw",
|
|
116
|
-
asBufferSource(b64urlDecode(p256dh)),
|
|
117
|
-
{ name: "ECDH", namedCurve: "P-256" },
|
|
118
|
-
true,
|
|
119
|
-
[],
|
|
120
|
-
);
|
|
121
|
-
const local = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
|
122
|
-
"deriveBits",
|
|
123
|
-
]);
|
|
124
|
-
const shared = new Uint8Array(
|
|
125
|
-
await crypto.subtle.deriveBits({ name: "ECDH", public: userPublic }, local.privateKey, 256),
|
|
126
|
-
);
|
|
127
|
-
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
128
|
-
const authSecret = b64urlDecode(auth);
|
|
129
|
-
|
|
130
|
-
// HKDF-SHA-256 as in RFC 8291
|
|
131
|
-
const ikmKey = await crypto.subtle.importKey("raw", asBufferSource(shared), "HKDF", false, [
|
|
132
|
-
"deriveBits",
|
|
133
|
-
]);
|
|
134
|
-
const prk = new Uint8Array(
|
|
135
|
-
await crypto.subtle.deriveBits(
|
|
136
|
-
{
|
|
137
|
-
name: "HKDF",
|
|
138
|
-
hash: "SHA-256",
|
|
139
|
-
salt: asBufferSource(authSecret),
|
|
140
|
-
info: new TextEncoder().encode("WebPush: info\0"),
|
|
141
|
-
},
|
|
142
|
-
ikmKey,
|
|
143
|
-
256,
|
|
144
|
-
),
|
|
145
|
-
);
|
|
146
|
-
// Simplified content encryption key / nonce derivation for aes128gcm
|
|
147
|
-
const prkKey = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits"]);
|
|
148
|
-
const cek = new Uint8Array(
|
|
149
|
-
await crypto.subtle.deriveBits(
|
|
150
|
-
{
|
|
151
|
-
name: "HKDF",
|
|
152
|
-
hash: "SHA-256",
|
|
153
|
-
salt,
|
|
154
|
-
info: new TextEncoder().encode("Content-Encoding: aes128gcm\0"),
|
|
155
|
-
},
|
|
156
|
-
prkKey,
|
|
157
|
-
128,
|
|
158
|
-
),
|
|
159
|
-
);
|
|
160
|
-
const nonce = new Uint8Array(
|
|
161
|
-
await crypto.subtle.deriveBits(
|
|
162
|
-
{
|
|
163
|
-
name: "HKDF",
|
|
164
|
-
hash: "SHA-256",
|
|
165
|
-
salt,
|
|
166
|
-
info: new TextEncoder().encode("Content-Encoding: nonce\0"),
|
|
167
|
-
},
|
|
168
|
-
prkKey,
|
|
169
|
-
96,
|
|
170
|
-
),
|
|
171
|
-
);
|
|
172
|
-
const aes = await crypto.subtle.importKey("raw", cek, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
173
|
-
// Add RFC 8188 padding delimiter (0x02) then encrypt
|
|
174
|
-
const padded = new Uint8Array(plaintext.length + 1);
|
|
175
|
-
padded.set(plaintext, 0);
|
|
176
|
-
padded[plaintext.length] = 2;
|
|
177
|
-
const ciphertext = new Uint8Array(
|
|
178
|
-
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aes, padded),
|
|
179
|
-
);
|
|
180
|
-
const localPublic = new Uint8Array(await crypto.subtle.exportKey("raw", local.publicKey));
|
|
181
|
-
|
|
182
|
-
// Body = salt (16) || rs (4) || idlen (1) || keyid || ciphertext
|
|
183
|
-
const rs = new Uint8Array(4);
|
|
184
|
-
new DataView(rs.buffer).setUint32(0, 4096);
|
|
185
|
-
const idlen = new Uint8Array([localPublic.length]);
|
|
186
|
-
const body = new Uint8Array(16 + 4 + 1 + localPublic.length + ciphertext.length);
|
|
187
|
-
body.set(salt, 0);
|
|
188
|
-
body.set(rs, 16);
|
|
189
|
-
body.set(idlen, 20);
|
|
190
|
-
body.set(localPublic, 21);
|
|
191
|
-
body.set(ciphertext, 21 + localPublic.length);
|
|
192
|
-
return { body, salt, localPublic };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
18
|
/**
|
|
196
19
|
* Open a Web Push driver (RFC 8030 + VAPID).
|
|
197
20
|
*
|
|
198
21
|
* @param options - VAPID keys + subject
|
|
199
22
|
*/
|
|
200
23
|
export function openWebPushChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
201
|
-
const
|
|
202
|
-
const
|
|
24
|
+
const vapidPublicKey = options.vapidPublicKey;
|
|
25
|
+
const vapidPrivateKey = options.vapidPrivateKey;
|
|
203
26
|
const subject = options.vapidSubject ?? "mailto:ops@oke.local";
|
|
204
|
-
|
|
27
|
+
if (!vapidPublicKey || !vapidPrivateKey) {
|
|
28
|
+
throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const transport = new WebPushTransport({
|
|
32
|
+
vapidPublicKey,
|
|
33
|
+
vapidPrivateKey,
|
|
34
|
+
subject,
|
|
35
|
+
});
|
|
36
|
+
const sender = createPushSender({ transport });
|
|
205
37
|
|
|
206
38
|
const channel: ChannelTransport = {
|
|
207
39
|
provider: "webpush",
|
|
208
40
|
mediums: ["push"],
|
|
209
41
|
async send(message: ChannelMessage): Promise<ChannelSendResult> {
|
|
210
|
-
if (!vapidPrivate || !vapidPublic) {
|
|
211
|
-
throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
|
|
212
|
-
}
|
|
213
42
|
const sub = message.pushSubscription;
|
|
214
43
|
const endpoint = sub?.endpoint ?? message.to;
|
|
215
|
-
if (!endpoint.startsWith("http")) {
|
|
216
|
-
throw new Error("webpush: pushSubscription
|
|
217
|
-
}
|
|
218
|
-
const audience = new URL(endpoint).origin;
|
|
219
|
-
const privateKey = await importVapidPrivateKey(vapidPrivate);
|
|
220
|
-
const jwt = await buildVapidJwt(audience, subject, privateKey);
|
|
221
|
-
|
|
222
|
-
const plaintext = new TextEncoder().encode(
|
|
223
|
-
message.text ?? JSON.stringify(message.data ?? {}),
|
|
224
|
-
);
|
|
225
|
-
let body: Uint8Array = plaintext;
|
|
226
|
-
const headers: Record<string, string> = {
|
|
227
|
-
Authorization: `vapid t=${jwt}, k=${vapidPublic}`,
|
|
228
|
-
TTL: "86400",
|
|
229
|
-
Urgency: "normal",
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
if (sub?.keys?.p256dh && sub.keys.auth) {
|
|
233
|
-
const enc = await encryptPushBody(plaintext, sub.keys.p256dh, sub.keys.auth);
|
|
234
|
-
body = enc.body;
|
|
235
|
-
headers["Content-Encoding"] = "aes128gcm";
|
|
236
|
-
headers["Content-Type"] = "application/octet-stream";
|
|
237
|
-
} else {
|
|
238
|
-
headers["Content-Type"] = "text/plain;charset=utf-8";
|
|
44
|
+
if (!endpoint.startsWith("http") || !sub?.keys?.p256dh || !sub.keys.auth) {
|
|
45
|
+
throw new Error("webpush: pushSubscription with endpoint + keys is required");
|
|
239
46
|
}
|
|
240
47
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
},
|
|
259
|
-
],
|
|
260
|
-
};
|
|
48
|
+
try {
|
|
49
|
+
const title =
|
|
50
|
+
message.subject ??
|
|
51
|
+
(typeof message.data?.title === "string" ? message.data.title : "Notification");
|
|
52
|
+
const body = message.text ?? JSON.stringify(message.data ?? {});
|
|
53
|
+
const result = await sender.send({
|
|
54
|
+
subscription: {
|
|
55
|
+
endpoint,
|
|
56
|
+
keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
|
57
|
+
},
|
|
58
|
+
title,
|
|
59
|
+
body,
|
|
60
|
+
...(message.data ? { data: { ...message.data } } : {}),
|
|
61
|
+
});
|
|
62
|
+
return mapSentlySendResult("webpush", result);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return mapSentlySendError("webpush", err);
|
|
261
65
|
}
|
|
262
|
-
return {
|
|
263
|
-
ok: true,
|
|
264
|
-
messageId: id,
|
|
265
|
-
driverId: "webpush",
|
|
266
|
-
attempts: [{ driverId: "webpush", ok: true, at: Date.now(), messageId: id }],
|
|
267
|
-
};
|
|
268
66
|
},
|
|
67
|
+
verify: () => sender.verify(),
|
|
68
|
+
close: () => sender.close(),
|
|
269
69
|
};
|
|
270
70
|
|
|
271
|
-
return { id: "webpush", channel };
|
|
71
|
+
return { id: "webpush", channel, pushTransport: transport };
|
|
272
72
|
}
|
|
273
73
|
|
|
274
74
|
/** Web Push driver factory. */
|
package/src/drivers/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type {
|
|
|
41
41
|
SignalDriver,
|
|
42
42
|
SignalBus,
|
|
43
43
|
SignalMessage,
|
|
44
|
+
SignalEmitOptions,
|
|
44
45
|
SignalFailureReason,
|
|
45
46
|
DeadLetter,
|
|
46
47
|
SignalTransaction,
|
|
@@ -55,6 +56,8 @@ export type {
|
|
|
55
56
|
SignalDiscardOptions,
|
|
56
57
|
} from "./signal-types.ts";
|
|
57
58
|
|
|
59
|
+
export { SIGNAL_DEFAULT_LEASE_MS } from "./signal-types.ts";
|
|
60
|
+
|
|
58
61
|
export {
|
|
59
62
|
memorySqlDriver,
|
|
60
63
|
memoryKvDriver,
|
|
@@ -139,10 +142,14 @@ export { createChannelInbox } from "./channel-types.ts";
|
|
|
139
142
|
export { consoleChannelDriver, openConsoleChannel } from "./channel-console.ts";
|
|
140
143
|
export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
|
|
141
144
|
export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
|
|
145
|
+
export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
|
|
146
|
+
export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
|
|
147
|
+
export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
|
|
142
148
|
export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
|
|
143
149
|
export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";
|
|
144
150
|
export { fcmChannelDriver, openFcmChannel } from "./channel-fcm.ts";
|
|
145
151
|
export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
|
|
152
|
+
export { mapSentlySendResult, mapSentlySendError } from "./channel-sently-map.ts";
|
|
146
153
|
|
|
147
154
|
export type {
|
|
148
155
|
AiDriverId,
|
|
@@ -11,21 +11,25 @@ import { dirname } from "node:path";
|
|
|
11
11
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
12
12
|
import type { SignalDelivery } from "../manifest/types.ts";
|
|
13
13
|
import { DryRunWriteIsolationError, setDryRunMessageId, withDryRun } from "../kernel/dry-run.ts";
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
import { OkeError, OKE_ERRORS } from "../kernel/errors.ts";
|
|
15
|
+
import {
|
|
16
|
+
SIGNAL_DEFAULT_LEASE_MS,
|
|
17
|
+
validateSignalEmitPayload,
|
|
18
|
+
type DeadLetter,
|
|
19
|
+
type LiveHandler,
|
|
20
|
+
type SignalBus,
|
|
21
|
+
type SignalDiscardOptions,
|
|
22
|
+
type SignalDriverId,
|
|
23
|
+
type SignalEmitOptions,
|
|
24
|
+
type SignalFailureReason,
|
|
25
|
+
type SignalHandler,
|
|
26
|
+
type SignalMessage,
|
|
27
|
+
type SignalOpenOptions,
|
|
28
|
+
type SignalReplayOptions,
|
|
29
|
+
type SignalReplayResult,
|
|
30
|
+
type SignalStats,
|
|
31
|
+
type SignalTransaction,
|
|
32
|
+
type SignalUnsubscribe,
|
|
29
33
|
} from "./signal-types.ts";
|
|
30
34
|
|
|
31
35
|
/** Internal mutable message. */
|
|
@@ -33,6 +37,8 @@ interface MutMessage {
|
|
|
33
37
|
id: string;
|
|
34
38
|
signal: string;
|
|
35
39
|
payload: unknown;
|
|
40
|
+
/** Optional per-key ordering token (`once` serialization). */
|
|
41
|
+
key: string | null;
|
|
36
42
|
delivery: SignalDelivery;
|
|
37
43
|
attempts: number;
|
|
38
44
|
failures: SignalFailureReason[];
|
|
@@ -41,6 +47,8 @@ interface MutMessage {
|
|
|
41
47
|
status: "pending" | "inflight" | "delivered" | "dead";
|
|
42
48
|
/** For once: which consumer holds the claim. */
|
|
43
49
|
lockedBy: string | null;
|
|
50
|
+
/** For once: when the claim lease expires (epoch ms); null when unlocked. */
|
|
51
|
+
leaseExpiresAt: number | null;
|
|
44
52
|
/** For broadcast: subscriber ids that already received. */
|
|
45
53
|
deliveredTo: Set<string>;
|
|
46
54
|
}
|
|
@@ -48,9 +56,10 @@ interface MutMessage {
|
|
|
48
56
|
/** Persisted snapshot for chaos recovery. */
|
|
49
57
|
interface Snapshot {
|
|
50
58
|
messages: Array<
|
|
51
|
-
Omit<MutMessage, "deliveredTo" | "lockedBy"> & {
|
|
59
|
+
Omit<MutMessage, "deliveredTo" | "lockedBy" | "leaseExpiresAt"> & {
|
|
52
60
|
deliveredTo: string[];
|
|
53
61
|
lockedBy: string | null;
|
|
62
|
+
leaseExpiresAt: number | null;
|
|
54
63
|
}
|
|
55
64
|
>;
|
|
56
65
|
writes: Array<[string, unknown]>;
|
|
@@ -76,6 +85,7 @@ export async function createSignalEngine(
|
|
|
76
85
|
const now = options.now ?? (() => Date.now());
|
|
77
86
|
const signals = options.signals;
|
|
78
87
|
const durablePath = options.durablePath;
|
|
88
|
+
const leaseMs = options.leaseMs ?? SIGNAL_DEFAULT_LEASE_MS;
|
|
79
89
|
|
|
80
90
|
let messages: MutMessage[] = [];
|
|
81
91
|
let writes = new Map<string, unknown>();
|
|
@@ -152,7 +162,9 @@ export async function createSignalEngine(
|
|
|
152
162
|
if (snap) {
|
|
153
163
|
messages = snap.messages.map((m) => ({
|
|
154
164
|
...m,
|
|
165
|
+
key: m.key ?? null,
|
|
155
166
|
lockedBy: m.lockedBy,
|
|
167
|
+
leaseExpiresAt: m.leaseExpiresAt ?? null,
|
|
156
168
|
deliveredTo: new Set(m.deliveredTo),
|
|
157
169
|
}));
|
|
158
170
|
writes = new Map(snap.writes);
|
|
@@ -166,6 +178,7 @@ export async function createSignalEngine(
|
|
|
166
178
|
id: m.id,
|
|
167
179
|
signal: m.signal,
|
|
168
180
|
payload: m.payload,
|
|
181
|
+
key: m.key,
|
|
169
182
|
delivery: m.delivery,
|
|
170
183
|
attempts: m.attempts,
|
|
171
184
|
failures: m.failures,
|
|
@@ -173,6 +186,7 @@ export async function createSignalEngine(
|
|
|
173
186
|
availableAt: m.availableAt,
|
|
174
187
|
status: m.status,
|
|
175
188
|
lockedBy: m.lockedBy,
|
|
189
|
+
leaseExpiresAt: m.leaseExpiresAt,
|
|
176
190
|
deliveredTo: [...m.deliveredTo],
|
|
177
191
|
})),
|
|
178
192
|
writes: [...writes.entries()],
|
|
@@ -189,13 +203,38 @@ export async function createSignalEngine(
|
|
|
189
203
|
return decl;
|
|
190
204
|
}
|
|
191
205
|
|
|
192
|
-
function
|
|
206
|
+
function hasSubscriber(name: string): boolean {
|
|
207
|
+
if (consumers.some((c) => c.signal === name)) return true;
|
|
208
|
+
const live = liveHandlers.get(name);
|
|
209
|
+
return live !== undefined && live.size > 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertEmitAllowed(name: string): void {
|
|
213
|
+
const decl = requireDecl(name);
|
|
214
|
+
if (decl.optional) return;
|
|
215
|
+
if (hasSubscriber(name)) return;
|
|
216
|
+
throw new OkeError(OKE_ERRORS.ORPHAN_EMIT, {
|
|
217
|
+
flow: "unknown",
|
|
218
|
+
resource: name,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function stageEmit(
|
|
223
|
+
stagedMessages: MutMessage[],
|
|
224
|
+
name: string,
|
|
225
|
+
payload: unknown,
|
|
226
|
+
options?: SignalEmitOptions,
|
|
227
|
+
): Promise<void> {
|
|
193
228
|
const decl = requireDecl(name);
|
|
229
|
+
assertEmitAllowed(name);
|
|
230
|
+
const value = await validateSignalEmitPayload(name, decl, payload);
|
|
194
231
|
const t = now();
|
|
232
|
+
const key = typeof options?.key === "string" && options.key.length > 0 ? options.key : null;
|
|
195
233
|
stagedMessages.push({
|
|
196
234
|
id: crypto.randomUUID(),
|
|
197
235
|
signal: name,
|
|
198
|
-
payload:
|
|
236
|
+
payload: value,
|
|
237
|
+
key,
|
|
199
238
|
delivery: decl.delivery,
|
|
200
239
|
attempts: 0,
|
|
201
240
|
failures: [],
|
|
@@ -203,6 +242,7 @@ export async function createSignalEngine(
|
|
|
203
242
|
availableAt: t,
|
|
204
243
|
status: "pending",
|
|
205
244
|
lockedBy: null,
|
|
245
|
+
leaseExpiresAt: null,
|
|
206
246
|
deliveredTo: new Set(),
|
|
207
247
|
});
|
|
208
248
|
}
|
|
@@ -218,9 +258,9 @@ export async function createSignalEngine(
|
|
|
218
258
|
if (done) throw new Error("transaction finished");
|
|
219
259
|
stagedWrites.set(key, value);
|
|
220
260
|
},
|
|
221
|
-
async emit(signal, payload) {
|
|
261
|
+
async emit(signal, payload, options) {
|
|
222
262
|
if (done) throw new Error("transaction finished");
|
|
223
|
-
stageEmit(stagedMessages, signal, payload);
|
|
263
|
+
await stageEmit(stagedMessages, signal, payload, options);
|
|
224
264
|
},
|
|
225
265
|
async commit() {
|
|
226
266
|
if (done) throw new Error("transaction finished");
|
|
@@ -240,9 +280,13 @@ export async function createSignalEngine(
|
|
|
240
280
|
return Promise.resolve(tx);
|
|
241
281
|
}
|
|
242
282
|
|
|
243
|
-
async function emit(
|
|
283
|
+
async function emit(
|
|
284
|
+
signal: string,
|
|
285
|
+
payload?: unknown,
|
|
286
|
+
options?: SignalEmitOptions,
|
|
287
|
+
): Promise<void> {
|
|
244
288
|
const tx = await begin();
|
|
245
|
-
await tx.emit(signal, payload);
|
|
289
|
+
await tx.emit(signal, payload, options);
|
|
246
290
|
await tx.commit();
|
|
247
291
|
}
|
|
248
292
|
|
|
@@ -287,6 +331,7 @@ export async function createSignalEngine(
|
|
|
287
331
|
id: m.id,
|
|
288
332
|
signal: m.signal,
|
|
289
333
|
payload: m.payload,
|
|
334
|
+
...(m.key !== null ? { key: m.key } : {}),
|
|
290
335
|
delivery: m.delivery,
|
|
291
336
|
attempts: m.attempts,
|
|
292
337
|
failures: [...m.failures],
|
|
@@ -296,18 +341,40 @@ export async function createSignalEngine(
|
|
|
296
341
|
};
|
|
297
342
|
}
|
|
298
343
|
|
|
344
|
+
/** True when another same-(signal, key) message holds an unexpired lease. */
|
|
345
|
+
function keyHeldByOther(candidate: MutMessage, t: number): boolean {
|
|
346
|
+
if (candidate.key === null) return false;
|
|
347
|
+
return messages.some(
|
|
348
|
+
(other) =>
|
|
349
|
+
other !== candidate &&
|
|
350
|
+
other.signal === candidate.signal &&
|
|
351
|
+
other.key === candidate.key &&
|
|
352
|
+
other.status === "inflight" &&
|
|
353
|
+
other.leaseExpiresAt !== null &&
|
|
354
|
+
other.leaseExpiresAt > t,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
299
358
|
/**
|
|
300
|
-
* Claim the next `once` message with SKIP LOCKED
|
|
359
|
+
* Claim the next `once` message with SKIP LOCKED + lazy lease reclaim.
|
|
360
|
+
*
|
|
361
|
+
* Eligible: pending/unlocked, or inflight whose `leaseExpiresAt` has passed,
|
|
362
|
+
* and not blocked by a same-key unexpired inflight (per-key serialization).
|
|
363
|
+
* No background sweeper — reclaim happens only at claim time.
|
|
301
364
|
*/
|
|
302
365
|
function claimOnce(signal: string, consumerId: string): MutMessage | null {
|
|
303
366
|
const t = now();
|
|
304
367
|
for (const m of messages) {
|
|
305
368
|
if (m.signal !== signal || m.delivery !== "once") continue;
|
|
306
|
-
if (m.status !== "pending") continue;
|
|
307
369
|
if (m.availableAt > t) continue;
|
|
308
|
-
|
|
370
|
+
const pendingOk = m.status === "pending" && m.lockedBy === null;
|
|
371
|
+
const leaseExpired =
|
|
372
|
+
m.status === "inflight" && m.leaseExpiresAt !== null && m.leaseExpiresAt <= t;
|
|
373
|
+
if (!pendingOk && !leaseExpired) continue;
|
|
374
|
+
if (keyHeldByOther(m, t)) continue;
|
|
309
375
|
m.status = "inflight";
|
|
310
376
|
m.lockedBy = consumerId;
|
|
377
|
+
m.leaseExpiresAt = t + leaseMs;
|
|
311
378
|
m.attempts += 1;
|
|
312
379
|
return m;
|
|
313
380
|
}
|
|
@@ -320,10 +387,12 @@ export async function createSignalEngine(
|
|
|
320
387
|
await consumer.handler(toPublic(m));
|
|
321
388
|
m.status = "delivered";
|
|
322
389
|
m.lockedBy = null;
|
|
390
|
+
m.leaseExpiresAt = null;
|
|
323
391
|
noteDelivered();
|
|
324
392
|
} catch (err) {
|
|
325
393
|
m.failures.push(failureFromError(err, m.attempts));
|
|
326
394
|
m.lockedBy = null;
|
|
395
|
+
m.leaseExpiresAt = null;
|
|
327
396
|
if (m.attempts > decl.retries) {
|
|
328
397
|
if (decl.deadLetter) {
|
|
329
398
|
m.status = "dead";
|
|
@@ -348,6 +417,9 @@ export async function createSignalEngine(
|
|
|
348
417
|
const m = claimOnce(consumer.signal, consumer.subscriberId);
|
|
349
418
|
if (!m) continue;
|
|
350
419
|
progress = true;
|
|
420
|
+
// Persist claim + lease before the handler runs so a crash mid-handler
|
|
421
|
+
// leaves a reclaimable inflight row (not a silent pending that never locked).
|
|
422
|
+
await persist();
|
|
351
423
|
await deliverOnce(m, consumer);
|
|
352
424
|
}
|
|
353
425
|
return progress;
|
|
@@ -571,6 +643,7 @@ export async function createSignalEngine(
|
|
|
571
643
|
if (!options.dryRun) {
|
|
572
644
|
m.status = "pending";
|
|
573
645
|
m.lockedBy = null;
|
|
646
|
+
m.leaseExpiresAt = null;
|
|
574
647
|
m.availableAt = now();
|
|
575
648
|
m.attempts = 0;
|
|
576
649
|
if (m.delivery === "broadcast") {
|
|
@@ -87,8 +87,8 @@ export async function openNatsSignal(options: SignalOpenOptions): Promise<Signal
|
|
|
87
87
|
|
|
88
88
|
return {
|
|
89
89
|
driverId: "nats",
|
|
90
|
-
async emit(signal, payload) {
|
|
91
|
-
await outbox.emit(signal, payload);
|
|
90
|
+
async emit(signal, payload, options) {
|
|
91
|
+
await outbox.emit(signal, payload, options);
|
|
92
92
|
await relayToNats(signal, payload);
|
|
93
93
|
},
|
|
94
94
|
async begin() {
|
|
@@ -96,9 +96,9 @@ export async function openNatsSignal(options: SignalOpenOptions): Promise<Signal
|
|
|
96
96
|
const tx = await outbox.begin();
|
|
97
97
|
return {
|
|
98
98
|
write: (k, v) => tx.write(k, v),
|
|
99
|
-
async emit(signal, payload) {
|
|
99
|
+
async emit(signal, payload, options) {
|
|
100
100
|
staged.push({ signal, payload });
|
|
101
|
-
await tx.emit(signal, payload);
|
|
101
|
+
await tx.emit(signal, payload, options);
|
|
102
102
|
},
|
|
103
103
|
async commit() {
|
|
104
104
|
await tx.commit();
|