@netnodeag/kraftwerk 0.71.0 → 0.72.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/README.md +7 -0
- package/dist/cli/daemon.js +4 -1
- package/dist/client/relay.js +2 -1
- package/dist/client/routes.d.ts +2 -0
- package/dist/client/routes.js +2 -0
- package/dist/core/devices.d.ts +21 -0
- package/dist/core/devices.js +26 -1
- package/dist/server/api/devices.d.ts +13 -2
- package/dist/server/api/devices.js +26 -1
- package/dist/server/api/index.d.ts +13 -2
- package/dist/server/push.d.ts +52 -0
- package/dist/server/push.js +67 -0
- package/dist/server/relay.d.ts +7 -0
- package/dist/server/relay.js +35 -0
- package/dist/server/ws.js +3 -0
- package/package.json +1 -1
- package/web/dist/assets/{dist-BcEOC-ps.js → dist-BLd_aA1g.js} +1 -1
- package/web/dist/assets/{dist-5bpaJOp6.js → dist-Bu0rxkzF.js} +1 -1
- package/web/dist/assets/{dist-BFetH-PC.js → dist-C8xoYPYE.js} +1 -1
- package/web/dist/assets/{dist-4mt46bwa.js → dist-CVuec2Mm.js} +1 -1
- package/web/dist/assets/{dist-Coej_0FF.js → dist-CYnXUgv0.js} +1 -1
- package/web/dist/assets/dist-D-uSPljm.js +1 -0
- package/web/dist/assets/{dist-CANEcq7F.js → dist-DFZgINkL.js} +1 -1
- package/web/dist/assets/{dist-BmsKU6ix.js → dist-GJo81x50.js} +1 -1
- package/web/dist/assets/{dist-Du3myOTO.js → dist-H3OFzCSU.js} +1 -1
- package/web/dist/assets/{dist-8As2tNSv.js → dist-LUiVqSl6.js} +1 -1
- package/web/dist/assets/{dist-FeMkWwZl.js → dist-WCCGMh5F.js} +1 -1
- package/web/dist/assets/{editor-B3am-j9U.js → editor-6D-WNjZY.js} +3 -3
- package/web/dist/assets/{index-CrBIiOwc.js → index-D8-2MY0Q.js} +3 -3
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-CWrRm3RK.js +0 -1
package/README.md
CHANGED
|
@@ -452,6 +452,13 @@ Everything between the phone and the machine is end to end encrypted:
|
|
|
452
452
|
- One trust remains: the remote page's code comes from the cloud that serves
|
|
453
453
|
it. A native app will remove that.
|
|
454
454
|
|
|
455
|
+
**Push notifications.** The kraftwerk iOS app gets a notification when
|
|
456
|
+
something new needs you: an approval, a question from an agent, a failed run
|
|
457
|
+
or routine. Each paired phone gives the machine a key of its own, and the
|
|
458
|
+
machine seals every notification with it. The relay and Apple pass along only
|
|
459
|
+
"Something needs you" and the badge count. The app opens the sealed text on
|
|
460
|
+
the phone. Turning notifications off on the phone, or unpairing it, stops them.
|
|
461
|
+
|
|
455
462
|
The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
|
|
456
463
|
(owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
|
|
457
464
|
must be awake.
|
package/dist/cli/daemon.js
CHANGED
|
@@ -2,7 +2,8 @@ import chalk from "chalk";
|
|
|
2
2
|
import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
|
|
3
3
|
import { tildify } from "../core/instances.js";
|
|
4
4
|
import { networkUrls } from "../server/bind.js";
|
|
5
|
-
import {
|
|
5
|
+
import { startPushWatcher } from "../server/push.js";
|
|
6
|
+
import { applyRelay, relayPush } from "../server/relay.js";
|
|
6
7
|
import { startHub } from "../server/server.js";
|
|
7
8
|
import { getPkgVersion } from "../server/version.js";
|
|
8
9
|
import { ensureBuilt, supervise } from "./ui.js";
|
|
@@ -49,6 +50,8 @@ export async function runDaemon(opts) {
|
|
|
49
50
|
console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
|
|
50
51
|
// The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
|
|
51
52
|
const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
|
|
53
|
+
// What needs you, pushed to the phones that asked (through the relay, sealed per device).
|
|
54
|
+
startPushWatcher(hub, relayPush);
|
|
52
55
|
if (relay.state !== "off")
|
|
53
56
|
console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
|
|
54
57
|
const lan = networkUrls(hub.port);
|
package/dist/client/relay.js
CHANGED
|
@@ -158,8 +158,9 @@ const OPEN = 1;
|
|
|
158
158
|
export async function connectTunnel(opts) {
|
|
159
159
|
const id = await relayId(opts.publicKey);
|
|
160
160
|
const url = `${opts.relayUrl}${opts.relayUrl.includes("?") ? "&" : "?"}id=${encodeURIComponent(id)}`;
|
|
161
|
-
|
|
161
|
+
// The keys first: once the socket exists, its events may fire at any await — the listeners below must already be there.
|
|
162
162
|
const hs = await clientHandshake(opts.publicKey);
|
|
163
|
+
const ws = opts.connect ? opts.connect(url) : new WebSocket(url);
|
|
163
164
|
let channel = null;
|
|
164
165
|
let closedWhy = "";
|
|
165
166
|
let resolveClosed;
|
package/dist/client/routes.d.ts
CHANGED
|
@@ -44,11 +44,13 @@ export declare const ROUTES: {
|
|
|
44
44
|
readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
|
|
45
45
|
readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
|
|
46
46
|
readonly "devices.code": readonly ["POST", "/api/devices/code"];
|
|
47
|
+
readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
|
|
47
48
|
readonly "devices.list": readonly ["GET", "/api/devices"];
|
|
48
49
|
readonly "devices.pair": readonly ["POST", "/api/pair"];
|
|
49
50
|
readonly "devices.relay": readonly ["GET", "/api/devices/relay"];
|
|
50
51
|
readonly "devices.revoke": readonly ["DELETE", "/api/devices/:id"];
|
|
51
52
|
readonly "devices.self": readonly ["GET", "/api/devices/self"];
|
|
53
|
+
readonly "devices.setPush": readonly ["POST", "/api/devices/push"];
|
|
52
54
|
readonly "devices.setRelay": readonly ["POST", "/api/devices/relay"];
|
|
53
55
|
readonly "files.delete": readonly ["DELETE", "/api/files"];
|
|
54
56
|
readonly "files.list": readonly ["GET", "/api/files/list"];
|
package/dist/client/routes.js
CHANGED
|
@@ -45,11 +45,13 @@ export const ROUTES = {
|
|
|
45
45
|
"chats.steer": ["POST", "/api/chats/:id/steer"],
|
|
46
46
|
"chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
|
|
47
47
|
"devices.code": ["POST", "/api/devices/code"],
|
|
48
|
+
"devices.dropPush": ["DELETE", "/api/devices/push"],
|
|
48
49
|
"devices.list": ["GET", "/api/devices"],
|
|
49
50
|
"devices.pair": ["POST", "/api/pair"],
|
|
50
51
|
"devices.relay": ["GET", "/api/devices/relay"],
|
|
51
52
|
"devices.revoke": ["DELETE", "/api/devices/:id"],
|
|
52
53
|
"devices.self": ["GET", "/api/devices/self"],
|
|
54
|
+
"devices.setPush": ["POST", "/api/devices/push"],
|
|
53
55
|
"devices.setRelay": ["POST", "/api/devices/relay"],
|
|
54
56
|
"files.delete": ["DELETE", "/api/files"],
|
|
55
57
|
"files.list": ["GET", "/api/files/list"],
|
package/dist/core/devices.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export interface Device {
|
|
|
15
15
|
name: string;
|
|
16
16
|
createdAt: string;
|
|
17
17
|
lastSeenAt?: string;
|
|
18
|
+
/** Whether the device gets push notifications (its app registered for them). */
|
|
19
|
+
push?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where a device's push notifications go: its APNs token and environment, and
|
|
23
|
+
* the key (32 bytes, base64) the device made for this machine — what is pushed
|
|
24
|
+
* is sealed with it, so the relay and Apple carry it without reading it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PushTarget {
|
|
27
|
+
token: string;
|
|
28
|
+
environment: "sandbox" | "production";
|
|
29
|
+
key: string;
|
|
18
30
|
}
|
|
19
31
|
/** How long a pairing code works. */
|
|
20
32
|
export declare const PAIR_CODE_TTL_MS: number;
|
|
@@ -33,3 +45,12 @@ export declare function deviceForToken(token: string): Promise<Device | null>;
|
|
|
33
45
|
export declare function listDevices(): Promise<Device[]>;
|
|
34
46
|
/** Unpair a device: its token stops working at once. */
|
|
35
47
|
export declare function revokeDevice(id: string): Promise<boolean>;
|
|
48
|
+
/** Register (or, with null, drop) where a device's push notifications go. */
|
|
49
|
+
export declare function setDevicePush(id: string, target: PushTarget | null): Promise<boolean>;
|
|
50
|
+
/** Every device that gets push notifications, with where they go. */
|
|
51
|
+
export declare function pushTargets(): Promise<Array<{
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
} & PushTarget>>;
|
|
55
|
+
/** Apple says a token is gone (the app was deleted): stop pushing to it. */
|
|
56
|
+
export declare function dropPushToken(token: string): Promise<void>;
|
package/dist/core/devices.js
CHANGED
|
@@ -88,7 +88,7 @@ function update(change) {
|
|
|
88
88
|
chain = run.catch(() => { });
|
|
89
89
|
return run;
|
|
90
90
|
}
|
|
91
|
-
const view = ({ tokenHash: _h, ...d }) => d;
|
|
91
|
+
const view = ({ tokenHash: _h, pushTarget, ...d }) => ({ ...d, ...(pushTarget ? { push: true } : {}) });
|
|
92
92
|
/** A new one-time pairing code, valid for PAIR_CODE_TTL_MS. Shown once; the store keeps its hash. */
|
|
93
93
|
export async function createPairCode() {
|
|
94
94
|
let code = "";
|
|
@@ -154,3 +154,28 @@ export async function revokeDevice(id) {
|
|
|
154
154
|
return store.devices.length < before;
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
|
+
/** Register (or, with null, drop) where a device's push notifications go. */
|
|
158
|
+
export async function setDevicePush(id, target) {
|
|
159
|
+
return update((store) => {
|
|
160
|
+
const d = store.devices.find((x) => x.id === id);
|
|
161
|
+
if (!d)
|
|
162
|
+
return false;
|
|
163
|
+
if (target)
|
|
164
|
+
d.pushTarget = target;
|
|
165
|
+
else
|
|
166
|
+
delete d.pushTarget;
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Every device that gets push notifications, with where they go. */
|
|
171
|
+
export async function pushTargets() {
|
|
172
|
+
return (await read()).devices.filter((d) => d.pushTarget).map((d) => ({ id: d.id, name: d.name, ...d.pushTarget }));
|
|
173
|
+
}
|
|
174
|
+
/** Apple says a token is gone (the app was deleted): stop pushing to it. */
|
|
175
|
+
export async function dropPushToken(token) {
|
|
176
|
+
await update((store) => {
|
|
177
|
+
for (const d of store.devices)
|
|
178
|
+
if (d.pushTarget?.token === token)
|
|
179
|
+
delete d.pushTarget;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -5,7 +5,9 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
|
|
|
5
5
|
expiresAt: string;
|
|
6
6
|
urls: string[];
|
|
7
7
|
remote?: string | undefined;
|
|
8
|
-
}, "/api/devices/code", undefined> | import("./router.js").Route<"devices.
|
|
8
|
+
}, "/api/devices/code", undefined> | import("./router.js").Route<"devices.dropPush", {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
}, "/api/devices/push", undefined> | import("./router.js").Route<"devices.list", {
|
|
9
11
|
devices: import("../../core/devices.js").Device[];
|
|
10
12
|
}, "/api/devices", undefined> | import("./router.js").Route<"devices.pair", import("./router.js").Reply<{
|
|
11
13
|
token: string;
|
|
@@ -29,7 +31,16 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
|
|
|
29
31
|
}, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
|
|
30
32
|
trust: "device" | "local" | "none";
|
|
31
33
|
device?: import("../../core/devices.js").Device | undefined;
|
|
32
|
-
}, "/api/devices/self", undefined> | import("./router.js").Route<"devices.
|
|
34
|
+
}, "/api/devices/self", undefined> | import("./router.js").Route<"devices.setPush", {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
}, "/api/devices/push", z.ZodObject<{
|
|
37
|
+
token: z.ZodString;
|
|
38
|
+
environment: z.ZodEnum<{
|
|
39
|
+
production: "production";
|
|
40
|
+
sandbox: "sandbox";
|
|
41
|
+
}>;
|
|
42
|
+
key: z.ZodString;
|
|
43
|
+
}, z.core.$strip>> | import("./router.js").Route<"devices.setRelay", {
|
|
33
44
|
state: import("../relay.js").RelayState;
|
|
34
45
|
id?: string;
|
|
35
46
|
key?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { createPairCode, listDevices, redeemPairCode, revokeDevice } from "../../core/devices.js";
|
|
2
|
+
import { createPairCode, listDevices, redeemPairCode, revokeDevice, setDevicePush } from "../../core/devices.js";
|
|
3
3
|
import { networkUrls } from "../bind.js";
|
|
4
4
|
import { currentHub } from "../hub-context.js";
|
|
5
5
|
import { applyRelay, keyFingerprint, readRelaySettings, relayStatus, remoteLink, setRelay } from "../relay.js";
|
|
@@ -48,6 +48,31 @@ export const deviceRoutes = [
|
|
|
48
48
|
trust: c.trust.kind,
|
|
49
49
|
...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
|
|
50
50
|
})),
|
|
51
|
+
// A paired device's app says where its push notifications go (and the key it seals them with); see server/push.ts.
|
|
52
|
+
route({
|
|
53
|
+
name: "devices.setPush",
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "/api/devices/push",
|
|
56
|
+
summary: "this device's push notifications {token, environment, key}",
|
|
57
|
+
errors: 400,
|
|
58
|
+
body: z.object({
|
|
59
|
+
token: z.string().regex(/^[0-9a-fA-F]{32,200}$/),
|
|
60
|
+
environment: z.enum(["sandbox", "production"]),
|
|
61
|
+
key: z.string().regex(/^[A-Za-z0-9+/]{43}=$/),
|
|
62
|
+
}),
|
|
63
|
+
}, async (c) => {
|
|
64
|
+
if (c.trust.kind !== "device")
|
|
65
|
+
fail(400, "only a paired device gets push notifications");
|
|
66
|
+
const { token, environment, key } = await c.body();
|
|
67
|
+
await setDevicePush(c.trust.device.id, { token: token.toLowerCase(), environment, key });
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}),
|
|
70
|
+
route({ name: "devices.dropPush", method: "DELETE", path: "/api/devices/push", summary: "no more push notifications for this device", errors: 400 }, async (c) => {
|
|
71
|
+
if (c.trust.kind !== "device")
|
|
72
|
+
fail(400, "only a paired device gets push notifications");
|
|
73
|
+
await setDevicePush(c.trust.device.id, null);
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}),
|
|
51
76
|
route({ name: "devices.list", method: "GET", path: "/api/devices", summary: "every paired device", access: "local" }, async () => ({ devices: await listDevices() })),
|
|
52
77
|
// The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only),
|
|
53
78
|
// and with the relay on, `remote` is the link that pairs a device from anywhere (code and machine key in its fragment).
|
|
@@ -289,7 +289,9 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
289
289
|
expiresAt: string;
|
|
290
290
|
urls: string[];
|
|
291
291
|
remote?: string | undefined;
|
|
292
|
-
}, "/api/devices/code", undefined> | Route<"devices.
|
|
292
|
+
}, "/api/devices/code", undefined> | Route<"devices.dropPush", {
|
|
293
|
+
ok: boolean;
|
|
294
|
+
}, "/api/devices/push", undefined> | Route<"devices.list", {
|
|
293
295
|
devices: import("../../core/devices.js").Device[];
|
|
294
296
|
}, "/api/devices", undefined> | Route<"devices.pair", import("./router.js").Reply<{
|
|
295
297
|
token: string;
|
|
@@ -313,7 +315,16 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
313
315
|
}, "/api/devices/:id", undefined> | Route<"devices.self", {
|
|
314
316
|
trust: "device" | "local" | "none";
|
|
315
317
|
device?: import("../../core/devices.js").Device | undefined;
|
|
316
|
-
}, "/api/devices/self", undefined> | Route<"devices.
|
|
318
|
+
}, "/api/devices/self", undefined> | Route<"devices.setPush", {
|
|
319
|
+
ok: boolean;
|
|
320
|
+
}, "/api/devices/push", z.ZodObject<{
|
|
321
|
+
token: z.ZodString;
|
|
322
|
+
environment: z.ZodEnum<{
|
|
323
|
+
production: "production";
|
|
324
|
+
sandbox: "sandbox";
|
|
325
|
+
}>;
|
|
326
|
+
key: z.ZodString;
|
|
327
|
+
}, z.core.$strip>> | Route<"devices.setRelay", {
|
|
317
328
|
state: import("../relay.js").RelayState;
|
|
318
329
|
id?: string;
|
|
319
330
|
key?: string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AttentionItem } from "../core/attention.js";
|
|
2
|
+
import type { Hub } from "./server.js";
|
|
3
|
+
/**
|
|
4
|
+
* Push notifications for what needs you: the daemon looks at every open
|
|
5
|
+
* workspace's attention list (approvals, questions, failures) and, for each
|
|
6
|
+
* item that is new, sends every device that asked for pushes a notification
|
|
7
|
+
* through the relay — sealed with the key that device made for this machine
|
|
8
|
+
* (AES-256-GCM), so the relay and Apple carry it without reading it. On the
|
|
9
|
+
* phone, the app's notification extension opens it and shows the text; what
|
|
10
|
+
* travels in the clear is only "something needs you" and the badge count.
|
|
11
|
+
*
|
|
12
|
+
* → relay {t:"push", pushes:[{token, environment, sealed, badge, thread}]}
|
|
13
|
+
* ← relay {t:"push_invalid", token} Apple says the token is gone
|
|
14
|
+
*
|
|
15
|
+
* Items present when the daemon starts are not pushed (a restart must not
|
|
16
|
+
* ring every phone); a resolved item that comes back is new again.
|
|
17
|
+
*/
|
|
18
|
+
/** What a notification says, before it is sealed. The phone reads these names. */
|
|
19
|
+
export interface PushContent {
|
|
20
|
+
title: string;
|
|
21
|
+
body: string;
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
/** The workspace (slug) and the chat to open on tap. */
|
|
24
|
+
ws: string;
|
|
25
|
+
chat?: string;
|
|
26
|
+
kind: AttentionItem["kind"];
|
|
27
|
+
}
|
|
28
|
+
export interface PushMessage {
|
|
29
|
+
token: string;
|
|
30
|
+
environment: "sandbox" | "production";
|
|
31
|
+
/** base64(nonce ‖ ciphertext ‖ tag) of the JSON PushContent. */
|
|
32
|
+
sealed: string;
|
|
33
|
+
badge: number;
|
|
34
|
+
/** Groups a workspace's notifications on the phone. */
|
|
35
|
+
thread: string;
|
|
36
|
+
}
|
|
37
|
+
/** Seal a notification's content with a device's key (base64, 32 bytes). */
|
|
38
|
+
export declare function sealPush(keyBase64: string, content: PushContent): string;
|
|
39
|
+
export declare function contentOf(item: AttentionItem, workspace: {
|
|
40
|
+
slug: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}): PushContent;
|
|
43
|
+
export interface PushWatcher {
|
|
44
|
+
/** Look now (tests; the timer does it every `everyMs`). */
|
|
45
|
+
scan(): Promise<number>;
|
|
46
|
+
stop(): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Watch the hub's workspaces and hand each new item's notifications to `send`
|
|
50
|
+
* (the relay link); returns how many were sent per scan.
|
|
51
|
+
*/
|
|
52
|
+
export declare function startPushWatcher(hub: Hub, send: (pushes: PushMessage[]) => boolean, everyMs?: number): PushWatcher;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import { listAttention } from "../core/attention.js";
|
|
3
|
+
import { pushTargets } from "../core/devices.js";
|
|
4
|
+
/** Seal a notification's content with a device's key (base64, 32 bytes). */
|
|
5
|
+
export function sealPush(keyBase64, content) {
|
|
6
|
+
const key = Buffer.from(keyBase64, "base64");
|
|
7
|
+
if (key.length !== 32)
|
|
8
|
+
throw new Error("a push key is 32 bytes");
|
|
9
|
+
const nonce = randomBytes(12);
|
|
10
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
11
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(content), "utf8"), cipher.final()]);
|
|
12
|
+
return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString("base64");
|
|
13
|
+
}
|
|
14
|
+
const LABEL = { approval: "Approval", question: "Question", failed: "Failed" };
|
|
15
|
+
export function contentOf(item, workspace) {
|
|
16
|
+
return {
|
|
17
|
+
title: `${LABEL[item.kind] ?? "Needs you"} · ${workspace.name}`,
|
|
18
|
+
body: item.title,
|
|
19
|
+
...(item.chatTitle ? { subtitle: item.chatTitle } : {}),
|
|
20
|
+
ws: workspace.slug,
|
|
21
|
+
...(item.chatId ? { chat: item.chatId } : {}),
|
|
22
|
+
kind: item.kind,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Watch the hub's workspaces and hand each new item's notifications to `send`
|
|
27
|
+
* (the relay link); returns how many were sent per scan.
|
|
28
|
+
*/
|
|
29
|
+
export function startPushWatcher(hub, send, everyMs = 10_000) {
|
|
30
|
+
let seen = null;
|
|
31
|
+
let running = false;
|
|
32
|
+
const scan = async () => {
|
|
33
|
+
if (running)
|
|
34
|
+
return 0;
|
|
35
|
+
running = true;
|
|
36
|
+
try {
|
|
37
|
+
const now = new Map();
|
|
38
|
+
for (const open of hub.list()) {
|
|
39
|
+
const view = await open.ws.run(() => listAttention()).catch(() => null);
|
|
40
|
+
for (const item of view?.items ?? [])
|
|
41
|
+
now.set(`${open.slug}\n${item.id}`, { item, ws: { slug: open.slug, name: open.name } });
|
|
42
|
+
}
|
|
43
|
+
const first = seen === null;
|
|
44
|
+
const fresh = first ? [] : [...now.entries()].filter(([k]) => !seen.has(k)).map(([, v]) => v);
|
|
45
|
+
seen = new Set(now.keys());
|
|
46
|
+
if (!fresh.length)
|
|
47
|
+
return 0;
|
|
48
|
+
const targets = await pushTargets();
|
|
49
|
+
if (!targets.length)
|
|
50
|
+
return 0;
|
|
51
|
+
const pushes = [];
|
|
52
|
+
for (const { item, ws } of fresh) {
|
|
53
|
+
const content = contentOf(item, ws);
|
|
54
|
+
for (const t of targets) {
|
|
55
|
+
pushes.push({ token: t.token, environment: t.environment, sealed: sealPush(t.key, content), badge: now.size, thread: ws.slug });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return send(pushes) ? pushes.length : 0;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
running = false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const timer = setInterval(() => void scan().catch(() => { }), everyMs);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
return { scan, stop: () => clearInterval(timer) };
|
|
67
|
+
}
|
package/dist/server/relay.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PushMessage } from "./push.js";
|
|
1
2
|
/**
|
|
2
3
|
* The daemon's end of the relay (see src/client/relay.ts for the protocol):
|
|
3
4
|
* an outgoing socket to the relay, one encrypted channel per client behind
|
|
@@ -53,6 +54,8 @@ export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client"
|
|
|
53
54
|
export declare function remoteLink(cloudUrl: string, key: string, code?: string, remoteUrl?: string | undefined): string;
|
|
54
55
|
export interface RelayLink {
|
|
55
56
|
status(): RelayStatus;
|
|
57
|
+
/** Hand push notifications to the relay (server/push.ts); false while not connected. */
|
|
58
|
+
push(pushes: PushMessage[]): boolean;
|
|
56
59
|
stop(): void;
|
|
57
60
|
}
|
|
58
61
|
export interface RelayOptions {
|
|
@@ -62,6 +65,8 @@ export interface RelayOptions {
|
|
|
62
65
|
version: string;
|
|
63
66
|
/** Opens the relay socket (default: Node's global WebSocket) — tests pass their own. */
|
|
64
67
|
connect?: (url: string) => WebSocket;
|
|
68
|
+
/** Give up on a connection the relay has not accepted (`ok`) within this long, and try again (default 15 s). */
|
|
69
|
+
handshakeTimeoutMs?: number;
|
|
65
70
|
log?: (line: string) => void;
|
|
66
71
|
}
|
|
67
72
|
/** Hold the daemon's socket to the relay, reconnecting with backoff, and answer every client behind it. */
|
|
@@ -70,6 +75,8 @@ export declare function startRelay(opts: RelayOptions): Promise<RelayLink>;
|
|
|
70
75
|
export declare const keyFingerprint: (key: string) => string;
|
|
71
76
|
/** Start, restart or stop this daemon's relay link to match ~/.kraftwerk/relay.json. */
|
|
72
77
|
export declare function applyRelay(port: number, version: string, log?: (line: string) => void): Promise<RelayStatus>;
|
|
78
|
+
/** Hand push notifications to this daemon's relay link; false without one. */
|
|
79
|
+
export declare const relayPush: (pushes: PushMessage[]) => boolean;
|
|
73
80
|
/** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
|
|
74
81
|
export declare const relayStatus: () => RelayStatus;
|
|
75
82
|
export declare function stopRelay(): void;
|
package/dist/server/relay.js
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
|
|
7
7
|
import { CLOUD_DEFAULT_URL } from "../config.js";
|
|
8
|
+
import { dropPushToken } from "../core/devices.js";
|
|
8
9
|
const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
|
|
9
10
|
export async function readRelaySettings() {
|
|
10
11
|
try {
|
|
@@ -189,6 +190,22 @@ export async function startRelay(opts) {
|
|
|
189
190
|
return schedule();
|
|
190
191
|
}
|
|
191
192
|
ws = socket;
|
|
193
|
+
// A relay that restarts can leave a new socket hanging half-open, neither opening nor
|
|
194
|
+
// closing: without this, the link would wait for it forever.
|
|
195
|
+
const handshake = setTimeout(() => {
|
|
196
|
+
if (ws !== socket || status.state === "connected")
|
|
197
|
+
return;
|
|
198
|
+
ws = null;
|
|
199
|
+
try {
|
|
200
|
+
socket.close();
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
status.state = "error";
|
|
204
|
+
status.error = "the relay did not answer";
|
|
205
|
+
log("relay: no answer from the relay — trying again");
|
|
206
|
+
schedule();
|
|
207
|
+
}, opts.handshakeTimeoutMs ?? 15_000);
|
|
208
|
+
handshake.unref?.();
|
|
192
209
|
socket.addEventListener("open", () => {
|
|
193
210
|
lastHeard = Date.now();
|
|
194
211
|
socket.send(JSON.stringify({ t: "auth", id, key: opts.settings.publicKey, secret: opts.settings.secret, version: opts.version }));
|
|
@@ -203,6 +220,7 @@ export async function startRelay(opts) {
|
|
|
203
220
|
return;
|
|
204
221
|
}
|
|
205
222
|
if (msg.t === "ok") {
|
|
223
|
+
clearTimeout(handshake);
|
|
206
224
|
attempt = 0;
|
|
207
225
|
status.state = "connected";
|
|
208
226
|
status.error = undefined;
|
|
@@ -224,8 +242,13 @@ export async function startRelay(opts) {
|
|
|
224
242
|
}
|
|
225
243
|
else if (msg.t === "close" && msg.c)
|
|
226
244
|
dropConn(msg.c);
|
|
245
|
+
else if (msg.t === "push_invalid" && typeof msg.token === "string") {
|
|
246
|
+
// Apple no longer knows the token (the app was deleted, or pushes turned off): stop sending to it.
|
|
247
|
+
void dropPushToken(msg.token).catch(() => { });
|
|
248
|
+
}
|
|
227
249
|
});
|
|
228
250
|
socket.addEventListener("close", (ev) => {
|
|
251
|
+
clearTimeout(handshake);
|
|
229
252
|
if (ws !== socket)
|
|
230
253
|
return;
|
|
231
254
|
ws = null;
|
|
@@ -233,6 +256,8 @@ export async function startRelay(opts) {
|
|
|
233
256
|
dropConn(c);
|
|
234
257
|
if (stopped)
|
|
235
258
|
return;
|
|
259
|
+
if (status.state === "connected")
|
|
260
|
+
log("relay: connection lost — reconnecting");
|
|
236
261
|
status.state = "error";
|
|
237
262
|
status.error = ev.code === 4401 ? `the relay refused this machine: ${ev.reason || "wrong secret"}` : ev.reason || "the relay connection closed";
|
|
238
263
|
if (ev.code === 4401)
|
|
@@ -263,6 +288,14 @@ export async function startRelay(opts) {
|
|
|
263
288
|
connect();
|
|
264
289
|
return {
|
|
265
290
|
status: () => ({ ...status }),
|
|
291
|
+
push(pushes) {
|
|
292
|
+
if (status.state !== "connected" || !pushes.length)
|
|
293
|
+
return false;
|
|
294
|
+
// At most 100 per message: a burst (a routine failing in every workspace) stays well under the relay's message limit.
|
|
295
|
+
for (let i = 0; i < pushes.length; i += 100)
|
|
296
|
+
send({ t: "push", pushes: pushes.slice(i, i + 100) });
|
|
297
|
+
return true;
|
|
298
|
+
},
|
|
266
299
|
stop() {
|
|
267
300
|
stopped = true;
|
|
268
301
|
if (retry)
|
|
@@ -293,6 +326,8 @@ export async function applyRelay(port, version, log) {
|
|
|
293
326
|
link = await startRelay({ port, settings, version, log });
|
|
294
327
|
return relayStatus();
|
|
295
328
|
}
|
|
329
|
+
/** Hand push notifications to this daemon's relay link; false without one. */
|
|
330
|
+
export const relayPush = (pushes) => link?.push(pushes) ?? false;
|
|
296
331
|
/** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
|
|
297
332
|
export const relayStatus = () => link?.status() ?? { state: "off", clients: 0 };
|
|
298
333
|
export function stopRelay() {
|
package/dist/server/ws.js
CHANGED
|
@@ -9,6 +9,7 @@ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
9
9
|
/** One message (all its fragments) may not exceed this. */
|
|
10
10
|
const MAX_MESSAGE = 4_000_000;
|
|
11
11
|
const PING_MS = 25_000;
|
|
12
|
+
const CLOSE_GRACE_MS = 2_000;
|
|
12
13
|
/** Complete the upgrade, or answer 400 and return null when the request is not a WebSocket handshake. */
|
|
13
14
|
export function acceptWebSocket(req, socket, head) {
|
|
14
15
|
const key = req.headers["sec-websocket-key"];
|
|
@@ -55,6 +56,8 @@ export function acceptWebSocket(req, socket, head) {
|
|
|
55
56
|
payload.writeUInt16BE(code, 0);
|
|
56
57
|
payload.write(reason, 2);
|
|
57
58
|
socket.end(frame(0x8, payload));
|
|
59
|
+
// The peer should close its side now; one that does not (or never saw the frame) must not keep the connection — or us — waiting.
|
|
60
|
+
setTimeout(() => socket.destroy(), CLOSE_GRACE_MS).unref?.();
|
|
58
61
|
finish();
|
|
59
62
|
};
|
|
60
63
|
const parse = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netnodeag/kraftwerk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|