@ours.network/notifications 0.1.1 → 0.3.1-nightly.1
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 +122 -225
- package/dist/chat-target.d.ts +9 -0
- package/dist/chat-target.js +12 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +13 -445
- package/dist/presence.d.ts +12 -0
- package/dist/presence.js +55 -145
- package/dist/server.d.ts +26 -0
- package/dist/server.js +145 -0
- package/dist/store.d.ts +63 -0
- package/dist/store.js +181 -0
- package/dist/validation.d.ts +9 -0
- package/dist/validation.js +60 -0
- package/package.json +14 -24
- package/dist/config.js +0 -104
- package/dist/daemon.js +0 -353
- package/dist/http.js +0 -31
- package/dist/log-store.js +0 -48
- package/dist/provision.js +0 -29
- package/dist/push.js +0 -6
- package/dist/web-push-sender.js +0 -41
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +0 -21
- package/mufl_code/core/.github/FUNDING.yml +0 -3
- package/mufl_code/core/.github/workflows/docs.yml +0 -39
- package/mufl_code/core/.github/workflows/tests.yml +0 -66
- package/mufl_code/core/COMMERCIAL-LICENCE.md +0 -25
- package/mufl_code/core/COMPATIBILITY.md +0 -221
- package/mufl_code/core/CONTRIBUTING.md +0 -37
- package/mufl_code/core/LICENSE +0 -98
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +0 -290
- package/mufl_code/core/README.md +0 -91
- package/mufl_code/core/SECURITY.md +0 -40
- package/mufl_code/core/TRADEMARKS.md +0 -24
- package/mufl_code/core/a2a_capabilities.mm +0 -449
- package/mufl_code/core/a2a_cluster.mm +0 -601
- package/mufl_code/core/a2a_control.mm +0 -90
- package/mufl_code/core/a2a_messaging.mm +0 -4817
- package/mufl_code/core/a2a_monitoring.mm +0 -94
- package/mufl_code/core/a2a_notification_integration.mm +0 -144
- package/mufl_code/core/a2a_notifications.mm +0 -1127
- package/mufl_code/core/a2a_protocol.mm +0 -246
- package/mufl_code/core/a2a_versions.mm +0 -801
- package/mufl_code/core/config.mufl +0 -29
- package/mufl_code/core/docs/.vitepress/config.mts +0 -58
- package/mufl_code/core/docs/citations.json +0 -688
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +0 -50
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +0 -100
- package/mufl_code/core/docs/guide/03-wire-the-host.md +0 -154
- package/mufl_code/core/docs/guide/04-connect-and-message.md +0 -156
- package/mufl_code/core/docs/guide/05-test-your-app.md +0 -75
- package/mufl_code/core/docs/guide/index.md +0 -63
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +0 -77
- package/mufl_code/core/docs/how-it-works/cluster.md +0 -65
- package/mufl_code/core/docs/how-it-works/identity.md +0 -56
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +0 -76
- package/mufl_code/core/docs/how-it-works/messaging.md +0 -55
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +0 -72
- package/mufl_code/core/docs/how-it-works/overview.md +0 -45
- package/mufl_code/core/docs/how-it-works/versioning.md +0 -116
- package/mufl_code/core/docs/index.md +0 -27
- package/mufl_code/core/docs/package-lock.json +0 -3467
- package/mufl_code/core/docs/package.json +0 -15
- package/mufl_code/core/docs/reference/contributing.md +0 -26
- package/mufl_code/core/docs/reference/glossary.md +0 -108
- package/mufl_code/core/docs/reference/implementations.md +0 -23
- package/mufl_code/core/docs/reference/modules.md +0 -19
- package/mufl_code/core/docs/scripts/check-citations.mjs +0 -36
- package/mufl_code/core/docs/scripts/check-denylist.mjs +0 -30
- package/mufl_code/core/docs/scripts/gen-llms.mjs +0 -32
- package/mufl_code/core/docs/workflows/cluster.md +0 -89
- package/mufl_code/core/docs/workflows/contact-exchange.md +0 -60
- package/mufl_code/core/docs/workflows/contact-restore.md +0 -60
- package/mufl_code/core/docs/workflows/control-verbs.md +0 -70
- package/mufl_code/core/docs/workflows/index.md +0 -57
- package/mufl_code/core/docs/workflows/introductions.md +0 -60
- package/mufl_code/core/docs/workflows/messaging.md +0 -75
- package/mufl_code/core/docs/workflows/monitoring.md +0 -84
- package/mufl_code/core/docs/workflows/notifications.md +0 -109
- package/mufl_code/core/release-notes/0.1.md +0 -60
- package/mufl_code/core/release-notes/0.3.md +0 -22
- package/mufl_code/core/release-notes/0.4.md +0 -97
- package/mufl_code/core/tests/README.md +0 -83
- package/mufl_code/core/tests/compat/README.md +0 -82
- package/mufl_code/core/tests/compat/compat.mjs +0 -245
- package/mufl_code/core/tests/compat/compat_peer.mjs +0 -146
- package/mufl_code/core/tests/compat/run_compat.sh +0 -96
- package/mufl_code/core/tests/corpus.mjs +0 -139
- package/mufl_code/core/tests/corpus_actor.mu +0 -242
- package/mufl_code/core/tests/fatcompile_actor.mu +0 -52
- package/mufl_code/core/tests/mig.mjs +0 -805
- package/mufl_code/core/tests/mig_actor.mu +0 -518
- package/mufl_code/core/tests/migapp.mjs +0 -431
- package/mufl_code/core/tests/migapp_actor.mu +0 -294
- package/mufl_code/core/tests/mufl_semantics/run.sh +0 -69
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +0 -47
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +0 -11
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +0 -30
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +0 -19
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +0 -19
- package/mufl_code/core/tests/notif.mjs +0 -755
- package/mufl_code/core/tests/notif_actor.mu +0 -450
- package/mufl_code/core/tests/protocol_container.mm +0 -18
- package/mufl_code/core/tests/run.sh +0 -65
- package/mufl_code/core/tests/run_corpus.sh +0 -51
- package/mufl_code/core/tests/run_fatcompile.sh +0 -40
- package/mufl_code/core/tests/run_mig.sh +0 -51
- package/mufl_code/core/tests/run_migapp.sh +0 -51
- package/mufl_code/core/tests/run_notif.sh +0 -57
- package/mufl_code/core/tests/test.mjs +0 -750
- package/mufl_code/core/tests/test_actor.mu +0 -564
- package/mufl_code/core/tests/test_common.mjs +0 -93
- package/mufl_code/core/version.mm +0 -24
- package/mufl_code/packet.mu +0 -170
- package/mufl_code/protocol_container.mm +0 -18
- package/scripts/gen-vapid.mjs +0 -16
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ECDH } from 'node:crypto';
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(status, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function text(value, max) {
|
|
10
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
11
|
+
throw new HttpError(400, 'invalid field');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function object(value) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
16
|
+
throw new HttpError(400, 'expected object');
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function target(value) {
|
|
20
|
+
const path = text(value, 2048);
|
|
21
|
+
// Only first-party Fleet pages. No scheme-relative URLs, encoded slashes,
|
|
22
|
+
// backslashes or control characters which browsers could normalize elsewhere.
|
|
23
|
+
if (!/^\/fleet(?:\/|\?|$)/.test(path) || /[\\\x00-\x20]|%(?:2f|5c|00|0[ad])/i.test(path))
|
|
24
|
+
throw new HttpError(400, 'invalid navigation target');
|
|
25
|
+
const url = new URL(path, 'https://ours.invalid');
|
|
26
|
+
if (url.origin !== 'https://ours.invalid' || !/^\/fleet(?:\/|$)/.test(url.pathname))
|
|
27
|
+
throw new HttpError(400, 'invalid navigation target');
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
export function subscription(value) {
|
|
31
|
+
const item = object(value);
|
|
32
|
+
const endpoint = text(item.endpoint, 4096);
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(endpoint);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new HttpError(400, 'invalid push endpoint');
|
|
39
|
+
}
|
|
40
|
+
// Deliberately closed provider set: user URLs cannot target a local network,
|
|
41
|
+
// arbitrary DNS names or redirectors. Delivery does not follow redirects.
|
|
42
|
+
const host = url.hostname;
|
|
43
|
+
const allowed = ['fcm.googleapis.com', 'updates.push.services.mozilla.com', 'web.push.apple.com'].includes(host)
|
|
44
|
+
|| /^[a-z0-9-]+\.notify\.windows\.com$/.test(host);
|
|
45
|
+
if (!allowed || url.protocol !== 'https:' || url.port || url.username || url.password || url.hash || url.search || url.pathname === '/')
|
|
46
|
+
throw new HttpError(400, 'unsupported push endpoint');
|
|
47
|
+
const keys = object(item.keys);
|
|
48
|
+
const p256dh = text(keys.p256dh, 100);
|
|
49
|
+
const auth = text(keys.auth, 30);
|
|
50
|
+
if (!/^[A-Za-z0-9_-]+$/.test(p256dh) || !/^[A-Za-z0-9_-]+$/.test(auth)
|
|
51
|
+
|| Buffer.from(p256dh, 'base64url').length !== 65 || Buffer.from(auth, 'base64url').length !== 16)
|
|
52
|
+
throw new HttpError(400, 'invalid push keys');
|
|
53
|
+
try {
|
|
54
|
+
ECDH.convertKey(Buffer.from(p256dh, 'base64url'), 'prime256v1');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new HttpError(400, 'invalid push keys');
|
|
58
|
+
}
|
|
59
|
+
return { endpoint: url.href, keys: { p256dh, auth } };
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,43 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/notifications",
|
|
3
|
-
"version": "0.1.1",
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "FSL-1.1-Apache-2.0",
|
|
3
|
+
"version": "0.3.1-nightly.1",
|
|
4
|
+
"description": "Authenticated web push delivery and shared notification inbox",
|
|
6
5
|
"type": "module",
|
|
6
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
7
10
|
"bin": {
|
|
8
|
-
"ours-
|
|
11
|
+
"ours-notifications": "dist/cli.js"
|
|
9
12
|
},
|
|
10
13
|
"files": [
|
|
11
14
|
"dist",
|
|
12
|
-
"mufl_code",
|
|
13
|
-
"scripts/gen-vapid.mjs",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
18
20
|
"build": "tsc",
|
|
19
|
-
"
|
|
20
|
-
"start": "node dist/cli.js start",
|
|
21
|
-
"stop": "node dist/cli.js stop",
|
|
22
|
-
"status": "node dist/cli.js status",
|
|
23
|
-
"serve": "node dist/cli.js serve",
|
|
24
|
-
"test": "npm run build && node --test --test-force-exit --test-concurrency=1 tests/*.test.mjs"
|
|
25
|
-
},
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=18"
|
|
21
|
+
"test": "npm run build && node --test tests/*.test.mjs"
|
|
28
22
|
},
|
|
29
23
|
"dependencies": {
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"web-push": "^3.6.7",
|
|
33
|
-
"ws": "^8.18.0"
|
|
24
|
+
"web-push": "3.6.7",
|
|
25
|
+
"ws": "8.22.0"
|
|
34
26
|
},
|
|
35
27
|
"devDependencies": {
|
|
36
|
-
"@adapt-toolkit/broker": "0.10.12",
|
|
37
|
-
"@adapt-toolkit/mufl": "0.10.12",
|
|
38
28
|
"@types/node": "^22.0.0",
|
|
39
|
-
"@types/web-push": "^3.6.
|
|
40
|
-
"
|
|
41
|
-
"typescript": "^5.5.
|
|
29
|
+
"@types/web-push": "^3.6.4",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"typescript": "^5.5.4"
|
|
42
32
|
}
|
|
43
33
|
}
|
package/dist/config.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// Daemon configuration. Portable and not host-bound: every field resolves by
|
|
2
|
-
// precedence env > config.json > built-in default, so the same build runs as
|
|
3
|
-
// the public instance or any self-hosted one (WebPush needs no vendor creds).
|
|
4
|
-
//
|
|
5
|
-
// config.json lives at $OURS_NOTIFY_CONFIG, else <HOME>/.ours-notifications/
|
|
6
|
-
// config.json (a fixed home path, independent of a custom STATE_DIR — mirrors
|
|
7
|
-
// ours-mcp). STATE_DIR defaults to <HOME>/.ours-notifications. VAPID keys are
|
|
8
|
-
// read from <STATE_DIR>/vapid.{public,private} by default and are
|
|
9
|
-
// self-provisioned on first boot (see provision.ts); inline VAPID_* env still
|
|
10
|
-
// wins. DISCOVERY_PORT has no config-layer default — the CLI supplies the 8890
|
|
11
|
-
// product default so running `dist/daemon.js` directly (and the test suite)
|
|
12
|
-
// never binds a port unless asked.
|
|
13
|
-
import * as fs from 'node:fs';
|
|
14
|
-
import { resolve, dirname, join } from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { homedir } from 'node:os';
|
|
17
|
-
function homeBase(env) {
|
|
18
|
-
return env.HOME ?? env.USERPROFILE ?? homedir();
|
|
19
|
-
}
|
|
20
|
-
export function defaultStateDir(env = process.env) {
|
|
21
|
-
return join(homeBase(env), '.ours-notifications');
|
|
22
|
-
}
|
|
23
|
-
function configFilePath(env) {
|
|
24
|
-
return env.OURS_NOTIFY_CONFIG ?? join(homeBase(env), '.ours-notifications', 'config.json');
|
|
25
|
-
}
|
|
26
|
-
function readConfigFile(env) {
|
|
27
|
-
try {
|
|
28
|
-
const raw = fs.readFileSync(configFilePath(env), 'utf8');
|
|
29
|
-
const parsed = JSON.parse(raw);
|
|
30
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return {}; // missing or invalid config.json → no overrides
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function parsePort(raw, source) {
|
|
37
|
-
if (raw === undefined || raw === null || raw === '')
|
|
38
|
-
return undefined;
|
|
39
|
-
const port = Number(raw);
|
|
40
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
41
|
-
throw new Error(`${source} must be an integer in 1..65535, got: ${raw}`);
|
|
42
|
-
}
|
|
43
|
-
return port;
|
|
44
|
-
}
|
|
45
|
-
// Resolve every field by env > config.json > default. Never throws on VAPID
|
|
46
|
-
// (files may not exist yet — provisioning creates them). Broker is left
|
|
47
|
-
// undefined when unset; loadConfig enforces it.
|
|
48
|
-
export function resolveConfig(env = process.env) {
|
|
49
|
-
const file = readConfigFile(env);
|
|
50
|
-
const pick = (envKey, fileKey, def) => env[envKey] ?? (file[fileKey] != null ? String(file[fileKey]) : undefined) ?? def;
|
|
51
|
-
const flag = (envKey, fileKey) => env[envKey] === '1' || file[fileKey] === true || file[fileKey] === '1';
|
|
52
|
-
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
53
|
-
const stateDir = resolve(pick('STATE_DIR', 'stateDir', defaultStateDir(env)));
|
|
54
|
-
const unitDir = resolve(pick('UNIT_DIR', 'unitDir', join(repoRoot, 'mufl_code')));
|
|
55
|
-
const vapidPublicFile = resolve(pick('VAPID_PUBLIC_KEY_FILE', 'vapidPublicFile', join(stateDir, 'vapid.public')));
|
|
56
|
-
const vapidPrivateFile = resolve(pick('VAPID_PRIVATE_KEY_FILE', 'vapidPrivateFile', join(stateDir, 'vapid.private')));
|
|
57
|
-
return {
|
|
58
|
-
brokerUrl: pick('BROKER_URL', 'brokerUrl'),
|
|
59
|
-
stateDir,
|
|
60
|
-
unitDir,
|
|
61
|
-
vapidPublicFile,
|
|
62
|
-
vapidPrivateFile,
|
|
63
|
-
vapidSubject: pick('VAPID_SUBJECT', 'vapidSubject', 'mailto:ops@ours.network'),
|
|
64
|
-
pushDisabled: flag('PUSH_DISABLED', 'pushDisabled'),
|
|
65
|
-
pushTlsInsecure: flag('PUSH_TLS_INSECURE', 'pushTlsInsecure'),
|
|
66
|
-
discoveryPort: parsePort(env.DISCOVERY_PORT ?? file.discoveryPort, 'DISCOVERY_PORT'),
|
|
67
|
-
discoveryHost: pick('DISCOVERY_HOST', 'discoveryHost', '127.0.0.1'),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
// Full config with VAPID key CONTENTS. Inline VAPID_PUBLIC_KEY/PRIVATE_KEY env
|
|
71
|
-
// wins over the files. Throws if the broker is unset or VAPID is still missing
|
|
72
|
-
// (provisioning should have created it — run `ours-notify start`).
|
|
73
|
-
export function loadConfig(env = process.env) {
|
|
74
|
-
const r = resolveConfig(env);
|
|
75
|
-
if (!r.brokerUrl) {
|
|
76
|
-
throw new Error('BROKER_URL is required (env BROKER_URL or config.json "brokerUrl"), e.g. ws://127.0.0.1:9000');
|
|
77
|
-
}
|
|
78
|
-
const readFileTrim = (p) => {
|
|
79
|
-
try {
|
|
80
|
-
return fs.readFileSync(p, 'utf8').trim();
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return '';
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
const vapidPublicKey = (env.VAPID_PUBLIC_KEY?.trim()) || readFileTrim(r.vapidPublicFile);
|
|
87
|
-
const vapidPrivateKey = (env.VAPID_PRIVATE_KEY?.trim()) || readFileTrim(r.vapidPrivateFile);
|
|
88
|
-
if (!vapidPublicKey || !vapidPrivateKey) {
|
|
89
|
-
throw new Error(`VAPID keys missing under ${r.stateDir} — run \`ours-notify start\` (auto-provisions) ` +
|
|
90
|
-
`or generate them with scripts/gen-vapid.mjs`);
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
brokerUrl: r.brokerUrl,
|
|
94
|
-
stateDir: r.stateDir,
|
|
95
|
-
unitDir: r.unitDir,
|
|
96
|
-
vapidPublicKey,
|
|
97
|
-
vapidPrivateKey,
|
|
98
|
-
vapidSubject: r.vapidSubject,
|
|
99
|
-
pushDisabled: r.pushDisabled,
|
|
100
|
-
pushTlsInsecure: r.pushTlsInsecure,
|
|
101
|
-
discoveryPort: r.discoveryPort,
|
|
102
|
-
discoveryHost: r.discoveryHost,
|
|
103
|
-
};
|
|
104
|
-
}
|
package/dist/daemon.js
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
// The ours-notifications host daemon: boots the service packet via the
|
|
2
|
-
// @adapt-toolkit SDK, connects to a broker, persists identity + packet state
|
|
3
|
-
// across restarts, consumes the packet's $notify_agent events
|
|
4
|
-
// (notification_posted / marked_read / unregistered), keeps the app-side
|
|
5
|
-
// notification log (notifications.json), and performs WebPush egress.
|
|
6
|
-
//
|
|
7
|
-
// Run directly (node dist/daemon.js) it speaks a JSON-lines control protocol:
|
|
8
|
-
// stdin {"cmd":"invite","name":"..."} | {"cmd":"contacts"} | {"cmd":"export_vis"} | {"cmd":"status"}
|
|
9
|
-
// stdout {"reply":...} for commands; {"event":...} lines for daemon events
|
|
10
|
-
// (first line after boot: {"event":"ready","cid":...,"version":{...}}).
|
|
11
|
-
import * as fs from 'node:fs';
|
|
12
|
-
import { resolve } from 'node:path';
|
|
13
|
-
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import { createInterface } from 'node:readline';
|
|
15
|
-
import { adapt_wrapper } from '@adapt-toolkit/sdk/executables';
|
|
16
|
-
import { PacketWrapperConfigurator } from '@adapt-toolkit/sdk/wrappers';
|
|
17
|
-
import { object_to_adapt_value } from '@adapt-toolkit/sdk/wrapper';
|
|
18
|
-
import { loadConfig } from './config.js';
|
|
19
|
-
import { LogStore } from './log-store.js';
|
|
20
|
-
import { DisabledPushSender } from './push.js';
|
|
21
|
-
import { WebPushSender } from './web-push-sender.js';
|
|
22
|
-
import { startDiscoveryServer } from './http.js';
|
|
23
|
-
import { PresenceRegistry, attachPresenceServer } from './presence.js';
|
|
24
|
-
const SERVICE_NAME = 'ours notifications service';
|
|
25
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
-
export async function startDaemon(cfg) {
|
|
27
|
-
fs.mkdirSync(cfg.stateDir, { recursive: true });
|
|
28
|
-
const unitFile = fs.readdirSync(cfg.unitDir).find((f) => f.endsWith('.muflo'));
|
|
29
|
-
if (!unitFile)
|
|
30
|
-
throw new Error(`no .muflo unit in ${cfg.unitDir} — run scripts/compile-mufl.sh`);
|
|
31
|
-
const unitHash = unitFile.slice(0, -'.muflo'.length);
|
|
32
|
-
const unitBytes = new Uint8Array(fs.readFileSync(resolve(cfg.unitDir, unitFile)));
|
|
33
|
-
const keyPath = resolve(cfg.stateDir, 'identity.key');
|
|
34
|
-
const dataPath = resolve(cfg.stateDir, 'state_data.bin');
|
|
35
|
-
const wrapper = await adapt_wrapper.start(['--broker_address', cfg.brokerUrl, '--test_mode',
|
|
36
|
-
'--logger_config', '--level', 'WARNING', '--stdout', 'stderr', '--logger_config_end']);
|
|
37
|
-
wrapper.start();
|
|
38
|
-
await sleep(1500);
|
|
39
|
-
const logStore = new LogStore(cfg.stateDir);
|
|
40
|
-
const push = cfg.pushDisabled
|
|
41
|
-
? new DisabledPushSender()
|
|
42
|
-
: new WebPushSender(cfg.vapidSubject, cfg.vapidPublicKey, cfg.vapidPrivateKey, cfg.pushTlsInsecure);
|
|
43
|
-
const presence = new PresenceRegistry();
|
|
44
|
-
const listeners = [];
|
|
45
|
-
const emit = (e) => { for (const cb of listeners)
|
|
46
|
-
cb(e); };
|
|
47
|
-
const pending = [];
|
|
48
|
-
let pw = null;
|
|
49
|
-
const ro = (name, targ) => pw.packet.ExecuteTransaction(object_to_adapt_value({ name, targ }));
|
|
50
|
-
const mutate = (name, targ) => new Promise((res, rej) => {
|
|
51
|
-
const timer = setTimeout(() => rej(new Error(`${name} timed out`)), 20000);
|
|
52
|
-
pending.push({ resolve: res, reject: rej, timer });
|
|
53
|
-
pw.add_client_message(object_to_adapt_value({ name, targ }));
|
|
54
|
-
});
|
|
55
|
-
function saveState() {
|
|
56
|
-
try {
|
|
57
|
-
const bytes = Buffer.from(ro('::actor::export_state').Serialize());
|
|
58
|
-
// Per-contact tokens grow state O(recipients × contacts) — surface
|
|
59
|
-
// real-world numbers once the blob crosses 10 MB so capping (LRU/paging)
|
|
60
|
-
// can be revisited with data.
|
|
61
|
-
if (bytes.length > 10 * 1024 * 1024) {
|
|
62
|
-
console.error(`[daemon] state size ${(bytes.length / (1024 * 1024)).toFixed(1)} MB exceeds 10 MB — review per-contact token growth`);
|
|
63
|
-
}
|
|
64
|
-
const tmp = `${dataPath}.tmp`;
|
|
65
|
-
fs.writeFileSync(tmp, bytes);
|
|
66
|
-
fs.renameSync(tmp, dataPath);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
console.error('[daemon] failed to save state:', String(err));
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function readBindings(av) {
|
|
73
|
-
const out = [];
|
|
74
|
-
if (av.IsNil())
|
|
75
|
-
return out;
|
|
76
|
-
for (let i = 0;; i++) {
|
|
77
|
-
const b = av.Reduce(i);
|
|
78
|
-
if (b.IsNil())
|
|
79
|
-
break;
|
|
80
|
-
out.push({
|
|
81
|
-
binding_id: b.Reduce('binding_id').Visualize(),
|
|
82
|
-
endpoint: b.Reduce('endpoint').Visualize(),
|
|
83
|
-
p256dh: b.Reduce('p256dh').Visualize(),
|
|
84
|
-
auth: b.Reduce('auth').Visualize(),
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
return out;
|
|
88
|
-
}
|
|
89
|
-
function readIds(av) {
|
|
90
|
-
if (av.IsNil())
|
|
91
|
-
return null;
|
|
92
|
-
const out = [];
|
|
93
|
-
for (let i = 0;; i++) {
|
|
94
|
-
const e = av.Reduce(i);
|
|
95
|
-
if (e.IsNil())
|
|
96
|
-
break;
|
|
97
|
-
out.push(e.Visualize());
|
|
98
|
-
}
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
function handleNotifyAgent(payload) {
|
|
102
|
-
const event = payload.Reduce('event').Visualize();
|
|
103
|
-
if (event === 'notification_posted') {
|
|
104
|
-
const n = payload.Reduce('notification');
|
|
105
|
-
const record = {
|
|
106
|
-
notif_id: n.Reduce('notif_id').Visualize(),
|
|
107
|
-
wire_id: n.Reduce('wire_id').Visualize(),
|
|
108
|
-
recipient_cid: n.Reduce('recipient_cid').Visualize(),
|
|
109
|
-
sender_cid: n.Reduce('sender_cid').Visualize(),
|
|
110
|
-
payload: n.Reduce('payload').Visualize(),
|
|
111
|
-
date: n.Reduce('date').Visualize(),
|
|
112
|
-
};
|
|
113
|
-
const bindings = readBindings(payload.Reduce('bindings'));
|
|
114
|
-
// BUG-2 foreground gate: a live authed presence socket for the recipient
|
|
115
|
-
// means their PWA is frontmost — they already got this message in-app, so
|
|
116
|
-
// SUPPRESS the WebPush. Log it as 'read' for history/badge coherence.
|
|
117
|
-
// No socket ⇒ recipient is closed/backgrounded ⇒ push as usual. Self-
|
|
118
|
-
// healing: the socket drop clears presence instantly, so the next post pushes.
|
|
119
|
-
const suppressed = presence.isOnline(record.recipient_cid);
|
|
120
|
-
if (suppressed) {
|
|
121
|
-
logStore.append(record, 'read');
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
logStore.append(record);
|
|
125
|
-
// One WebPush per binding, log-and-continue on failure (E12) — never
|
|
126
|
-
// block or crash the ingest on a dead endpoint.
|
|
127
|
-
void push.sendPushes(bindings, record.payload).then((results) => {
|
|
128
|
-
for (const r of results) {
|
|
129
|
-
if (!r.ok)
|
|
130
|
-
console.error(`[push] ${r.binding_id}: ${r.error ?? r.status}`);
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
emit({ event, notification: record, bindings, suppressed });
|
|
135
|
-
}
|
|
136
|
-
else if (event === 'marked_read') {
|
|
137
|
-
const recipient = payload.Reduce('recipient_cid').Visualize();
|
|
138
|
-
const ids = readIds(payload.Reduce('notif_ids'));
|
|
139
|
-
logStore.markRead(recipient, ids);
|
|
140
|
-
emit({ event, recipient_cid: recipient, notif_ids: ids });
|
|
141
|
-
}
|
|
142
|
-
else if (event === 'unregistered') {
|
|
143
|
-
const recipient = payload.Reduce('recipient_cid').Visualize();
|
|
144
|
-
logStore.purgeRecipient(recipient);
|
|
145
|
-
emit({ event, recipient_cid: recipient });
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
emit({ event });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
function createPacket(seed, signingSecret) {
|
|
152
|
-
const pcfg = new PacketWrapperConfigurator();
|
|
153
|
-
const args = ['--unit_hash', unitHash, '--seed_phrase', seed, '--unit_dir_path', cfg.unitDir];
|
|
154
|
-
if (signingSecret)
|
|
155
|
-
args.push('--init_trn_argument', JSON.stringify(signingSecret));
|
|
156
|
-
pcfg.process_arguments(args);
|
|
157
|
-
return new Promise((res, rej) => {
|
|
158
|
-
const t = setTimeout(() => rej(new Error('packet creation timed out')), 30000);
|
|
159
|
-
wrapper.packet_manager.create_packet(pcfg, (p) => {
|
|
160
|
-
clearTimeout(t);
|
|
161
|
-
pw = p;
|
|
162
|
-
pw.on_return_data = (d) => {
|
|
163
|
-
const kind = d.Reduce('kind').Visualize();
|
|
164
|
-
if (kind === 'save_state') {
|
|
165
|
-
saveState();
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (kind === 'notify_agent') {
|
|
169
|
-
handleNotifyAgent(d.Reduce('payload'));
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const p2 = pending.shift();
|
|
173
|
-
if (p2) {
|
|
174
|
-
clearTimeout(p2.timer);
|
|
175
|
-
p2.resolve(d.Reduce('payload'));
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
pw.on_transaction_failure = (msg) => {
|
|
179
|
-
const p2 = pending.shift();
|
|
180
|
-
if (p2) {
|
|
181
|
-
clearTimeout(p2.timer);
|
|
182
|
-
p2.reject(new Error(msg));
|
|
183
|
-
}
|
|
184
|
-
else
|
|
185
|
-
console.error('[daemon] inbound rejected:', String(msg).split('\n')[0]);
|
|
186
|
-
};
|
|
187
|
-
res();
|
|
188
|
-
}, unitBytes);
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
// Boot: restore the persisted identity (same container id via reseed) or
|
|
192
|
-
// mint a fresh one and persist its SIGN secret + state.
|
|
193
|
-
if (fs.existsSync(keyPath)) {
|
|
194
|
-
const secret = fs.readFileSync(keyPath, 'utf8').trim();
|
|
195
|
-
await createPacket('', secret);
|
|
196
|
-
if (fs.existsSync(dataPath) && fs.statSync(dataPath).size > 0) {
|
|
197
|
-
const adaptData = pw.packet.ParseValue(new Uint8Array(fs.readFileSync(dataPath)));
|
|
198
|
-
await mutate('::actor::import_state', adaptData);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
await createPacket(randomBytes(24).toString('hex'));
|
|
203
|
-
const secretHex = Buffer.from(ro('::actor::export_signing_secret').Serialize()).toString('hex');
|
|
204
|
-
fs.writeFileSync(keyPath, secretHex, { mode: 0o600 });
|
|
205
|
-
}
|
|
206
|
-
// The packet echoes this in every confirm_registration; PRIVATE key stays here.
|
|
207
|
-
await mutate('::a2a_notifications::set_vapid_public_key', { key: cfg.vapidPublicKey });
|
|
208
|
-
const cid = pw.packet.GetContainerID().Visualize();
|
|
209
|
-
const vv = ro('::actor::get_version');
|
|
210
|
-
const version = {
|
|
211
|
-
MAJ: Number(vv.Reduce('MAJ').Visualize()),
|
|
212
|
-
MIN: Number(vv.Reduce('MIN').Visualize()),
|
|
213
|
-
PATCH: Number(vv.Reduce('PATCH').Visualize()),
|
|
214
|
-
};
|
|
215
|
-
const mintInvite = async (name = '') => {
|
|
216
|
-
const m = await mutate('::a2a_messaging::generate_invite', { name });
|
|
217
|
-
return Buffer.from(m.Reduce('invite').GetBinary()).toString('hex');
|
|
218
|
-
};
|
|
219
|
-
// BUG-2: verify a presence socket belongs to the claimed recipient — PURE TS,
|
|
220
|
-
// ZERO mufl (app+server-only by design). The panel presents
|
|
221
|
-
// { recipient, endpoint, auth } from its OWN WebPush subscription; we read the
|
|
222
|
-
// packet's registration state via the EXISTING ::actor::export_state readonly
|
|
223
|
-
// and check the pair against a stored binding of registration[recipient]. Runs
|
|
224
|
-
// only at socket-auth time (foreground reconnect), not per notification, so the
|
|
225
|
-
// export_state cost is fine. Returns the bound cid on success, else null; never
|
|
226
|
-
// throws into the WS layer.
|
|
227
|
-
const verifyPresence = (frame) => {
|
|
228
|
-
const recipient = frame.recipient;
|
|
229
|
-
const endpoint = frame.endpoint;
|
|
230
|
-
const auth = frame.auth;
|
|
231
|
-
if (typeof recipient !== 'string' || typeof endpoint !== 'string' || typeof auth !== 'string')
|
|
232
|
-
return null;
|
|
233
|
-
if (!recipient || !endpoint || !auth)
|
|
234
|
-
return null;
|
|
235
|
-
try {
|
|
236
|
-
const notify = ro('::actor::export_state').Reduce('notify');
|
|
237
|
-
if (notify.IsNil())
|
|
238
|
-
return null;
|
|
239
|
-
const regs = notify.Reduce('notify_registrations');
|
|
240
|
-
if (regs.IsNil())
|
|
241
|
-
return null;
|
|
242
|
-
// notify_registrations is a global_id-keyed map; find the recipient's entry.
|
|
243
|
-
let reg = null;
|
|
244
|
-
for (const k of regs.GetKeys()) {
|
|
245
|
-
if (k.Visualize() === recipient) {
|
|
246
|
-
reg = regs.Reduce(k);
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
if (!reg || reg.IsNil())
|
|
251
|
-
return null;
|
|
252
|
-
const bindings = reg.Reduce('bindings');
|
|
253
|
-
if (bindings.IsNil())
|
|
254
|
-
return null;
|
|
255
|
-
for (let i = 0;; i++) {
|
|
256
|
-
const b = bindings.Reduce(i);
|
|
257
|
-
if (b.IsNil())
|
|
258
|
-
break;
|
|
259
|
-
if (b.Reduce('endpoint').Visualize() === endpoint && b.Reduce('auth').Visualize() === auth) {
|
|
260
|
-
return recipient;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
catch (err) {
|
|
266
|
-
console.error('[presence] verify failed:', String(err).split('\n')[0]);
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
let discovery;
|
|
271
|
-
let presenceServer;
|
|
272
|
-
if (cfg.discoveryPort !== undefined) {
|
|
273
|
-
discovery = await startDiscoveryServer(cfg.discoveryPort, cfg.discoveryHost, {
|
|
274
|
-
serviceName: () => SERVICE_NAME,
|
|
275
|
-
serviceCid: () => cid,
|
|
276
|
-
brokerUrl: cfg.brokerUrl,
|
|
277
|
-
vapidPublicKey: cfg.vapidPublicKey,
|
|
278
|
-
mintInvite,
|
|
279
|
-
});
|
|
280
|
-
// Attach the foreground-presence WebSocket to the SAME server/port (reachable
|
|
281
|
-
// over the same tunnel as /v1/discovery). No new port, no new tunnel route.
|
|
282
|
-
presenceServer = attachPresenceServer(discovery, presence, {
|
|
283
|
-
verify: verifyPresence,
|
|
284
|
-
onChange: (recipient_cid, online) => emit({ event: 'presence_change', recipient_cid, online }),
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
cid,
|
|
289
|
-
version,
|
|
290
|
-
logStore,
|
|
291
|
-
onEvent: (cb) => listeners.push(cb),
|
|
292
|
-
invite: async (name) => {
|
|
293
|
-
return mintInvite(name);
|
|
294
|
-
},
|
|
295
|
-
contacts: () => ro('::a2a_messaging::list_contacts').Visualize(),
|
|
296
|
-
exportVis: () => ro('::actor::export_state').Visualize(),
|
|
297
|
-
stop: async () => {
|
|
298
|
-
saveState();
|
|
299
|
-
if (presenceServer)
|
|
300
|
-
await presenceServer.close();
|
|
301
|
-
if (discovery)
|
|
302
|
-
await new Promise((res) => discovery.close(() => res()));
|
|
303
|
-
},
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
// ---- CLI entry (JSON-lines control protocol) --------------------------------
|
|
307
|
-
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.url.replace('file://', ''));
|
|
308
|
-
if (isMain) {
|
|
309
|
-
const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
310
|
-
startDaemon(loadConfig())
|
|
311
|
-
.then((daemon) => {
|
|
312
|
-
daemon.onEvent((e) => out(e));
|
|
313
|
-
out({ event: 'ready', cid: daemon.cid, version: daemon.version });
|
|
314
|
-
const rl = createInterface({ input: process.stdin });
|
|
315
|
-
rl.on('line', (line) => {
|
|
316
|
-
void (async () => {
|
|
317
|
-
let msg;
|
|
318
|
-
try {
|
|
319
|
-
msg = JSON.parse(line);
|
|
320
|
-
}
|
|
321
|
-
catch {
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
try {
|
|
325
|
-
if (msg.cmd === 'invite')
|
|
326
|
-
out({ reply: await daemon.invite(msg.name ?? '') });
|
|
327
|
-
else if (msg.cmd === 'contacts')
|
|
328
|
-
out({ reply: daemon.contacts() });
|
|
329
|
-
else if (msg.cmd === 'export_vis')
|
|
330
|
-
out({ reply: daemon.exportVis() });
|
|
331
|
-
else if (msg.cmd === 'status')
|
|
332
|
-
out({ reply: { cid: daemon.cid, version: daemon.version } });
|
|
333
|
-
else if (msg.cmd === 'notifications')
|
|
334
|
-
out({ reply: daemon.logStore.list() });
|
|
335
|
-
else
|
|
336
|
-
out({ reply: { error: `unknown cmd ${msg.cmd}` } });
|
|
337
|
-
}
|
|
338
|
-
catch (err) {
|
|
339
|
-
out({ reply: { error: String(err) } });
|
|
340
|
-
}
|
|
341
|
-
})();
|
|
342
|
-
});
|
|
343
|
-
const shutdown = () => {
|
|
344
|
-
void daemon.stop().then(() => process.exit(0));
|
|
345
|
-
};
|
|
346
|
-
process.on('SIGTERM', shutdown);
|
|
347
|
-
process.on('SIGINT', shutdown);
|
|
348
|
-
})
|
|
349
|
-
.catch((err) => {
|
|
350
|
-
console.error('[daemon] boot failed:', err);
|
|
351
|
-
process.exit(1);
|
|
352
|
-
});
|
|
353
|
-
}
|
package/dist/http.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
const CORS = { 'Access-Control-Allow-Origin': '*' };
|
|
3
|
-
export function startDiscoveryServer(port, host, deps) {
|
|
4
|
-
const server = http.createServer(async (req, res) => {
|
|
5
|
-
if (req.method === 'OPTIONS') {
|
|
6
|
-
res.writeHead(204, CORS);
|
|
7
|
-
return void res.end();
|
|
8
|
-
}
|
|
9
|
-
if (req.method === 'GET' && req.url?.split('?')[0] === '/v1/discovery') {
|
|
10
|
-
try {
|
|
11
|
-
const invite = await deps.mintInvite();
|
|
12
|
-
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
13
|
-
return void res.end(JSON.stringify({
|
|
14
|
-
version: 1,
|
|
15
|
-
service_name: deps.serviceName(),
|
|
16
|
-
service_cid: deps.serviceCid(),
|
|
17
|
-
broker_url: deps.brokerUrl,
|
|
18
|
-
vapid_public_key: deps.vapidPublicKey,
|
|
19
|
-
invite,
|
|
20
|
-
}));
|
|
21
|
-
}
|
|
22
|
-
catch (e) {
|
|
23
|
-
res.writeHead(500, { 'content-type': 'application/json', ...CORS });
|
|
24
|
-
return void res.end(JSON.stringify({ error: String(e) }));
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
res.writeHead(404, CORS);
|
|
28
|
-
res.end();
|
|
29
|
-
});
|
|
30
|
-
return new Promise((resolve) => server.listen(port, host, () => resolve(server)));
|
|
31
|
-
}
|
package/dist/log-store.js
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// The app-side notification log (notifications.json): the daemon owns
|
|
2
|
-
// storage + read/unread status; the packet only validates and hands records
|
|
3
|
-
// over. v1: unbounded append (documented; GC is an optional enhancement).
|
|
4
|
-
import * as fs from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
6
|
-
export class LogStore {
|
|
7
|
-
path;
|
|
8
|
-
records = [];
|
|
9
|
-
constructor(dir) {
|
|
10
|
-
this.path = resolve(dir, 'notifications.json');
|
|
11
|
-
try {
|
|
12
|
-
this.records = JSON.parse(fs.readFileSync(this.path, 'utf8'));
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
this.records = [];
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
flush() {
|
|
19
|
-
const tmp = `${this.path}.tmp`;
|
|
20
|
-
fs.writeFileSync(tmp, JSON.stringify(this.records, null, 2));
|
|
21
|
-
fs.renameSync(tmp, this.path);
|
|
22
|
-
}
|
|
23
|
-
// status defaults to 'unread'. Foreground-suppressed posts are appended as
|
|
24
|
-
// 'read' (BUG-2): the recipient already saw the message in-app, so the record
|
|
25
|
-
// is kept for history but must not inflate the unread badge.
|
|
26
|
-
append(rec, status = 'unread') {
|
|
27
|
-
this.records.push({ ...rec, status });
|
|
28
|
-
this.flush();
|
|
29
|
-
}
|
|
30
|
-
// ids === null ⇒ mark ALL of this recipient's notifications read.
|
|
31
|
-
// Unknown / foreign ids are ignored (E7 — idempotent).
|
|
32
|
-
markRead(recipientCid, ids) {
|
|
33
|
-
for (const r of this.records) {
|
|
34
|
-
if (r.recipient_cid !== recipientCid)
|
|
35
|
-
continue;
|
|
36
|
-
if (ids === null || ids.includes(r.notif_id))
|
|
37
|
-
r.status = 'read';
|
|
38
|
-
}
|
|
39
|
-
this.flush();
|
|
40
|
-
}
|
|
41
|
-
purgeRecipient(recipientCid) {
|
|
42
|
-
this.records = this.records.filter((r) => r.recipient_cid !== recipientCid);
|
|
43
|
-
this.flush();
|
|
44
|
-
}
|
|
45
|
-
list() {
|
|
46
|
-
return this.records;
|
|
47
|
-
}
|
|
48
|
-
}
|