@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.
Files changed (116) hide show
  1. package/README.md +122 -225
  2. package/dist/chat-target.d.ts +9 -0
  3. package/dist/chat-target.js +12 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +13 -445
  6. package/dist/presence.d.ts +12 -0
  7. package/dist/presence.js +55 -145
  8. package/dist/server.d.ts +26 -0
  9. package/dist/server.js +145 -0
  10. package/dist/store.d.ts +63 -0
  11. package/dist/store.js +181 -0
  12. package/dist/validation.d.ts +9 -0
  13. package/dist/validation.js +60 -0
  14. package/package.json +14 -24
  15. package/dist/config.js +0 -104
  16. package/dist/daemon.js +0 -353
  17. package/dist/http.js +0 -31
  18. package/dist/log-store.js +0 -48
  19. package/dist/provision.js +0 -29
  20. package/dist/push.js +0 -6
  21. package/dist/web-push-sender.js +0 -41
  22. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  23. package/mufl_code/config.mufl +0 -21
  24. package/mufl_code/core/.github/FUNDING.yml +0 -3
  25. package/mufl_code/core/.github/workflows/docs.yml +0 -39
  26. package/mufl_code/core/.github/workflows/tests.yml +0 -66
  27. package/mufl_code/core/COMMERCIAL-LICENCE.md +0 -25
  28. package/mufl_code/core/COMPATIBILITY.md +0 -221
  29. package/mufl_code/core/CONTRIBUTING.md +0 -37
  30. package/mufl_code/core/LICENSE +0 -98
  31. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +0 -290
  32. package/mufl_code/core/README.md +0 -91
  33. package/mufl_code/core/SECURITY.md +0 -40
  34. package/mufl_code/core/TRADEMARKS.md +0 -24
  35. package/mufl_code/core/a2a_capabilities.mm +0 -449
  36. package/mufl_code/core/a2a_cluster.mm +0 -601
  37. package/mufl_code/core/a2a_control.mm +0 -90
  38. package/mufl_code/core/a2a_messaging.mm +0 -4817
  39. package/mufl_code/core/a2a_monitoring.mm +0 -94
  40. package/mufl_code/core/a2a_notification_integration.mm +0 -144
  41. package/mufl_code/core/a2a_notifications.mm +0 -1127
  42. package/mufl_code/core/a2a_protocol.mm +0 -246
  43. package/mufl_code/core/a2a_versions.mm +0 -801
  44. package/mufl_code/core/config.mufl +0 -29
  45. package/mufl_code/core/docs/.vitepress/config.mts +0 -58
  46. package/mufl_code/core/docs/citations.json +0 -688
  47. package/mufl_code/core/docs/guide/01-vendor-the-core.md +0 -50
  48. package/mufl_code/core/docs/guide/02-configure-and-compile.md +0 -100
  49. package/mufl_code/core/docs/guide/03-wire-the-host.md +0 -154
  50. package/mufl_code/core/docs/guide/04-connect-and-message.md +0 -156
  51. package/mufl_code/core/docs/guide/05-test-your-app.md +0 -75
  52. package/mufl_code/core/docs/guide/index.md +0 -63
  53. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +0 -77
  54. package/mufl_code/core/docs/how-it-works/cluster.md +0 -65
  55. package/mufl_code/core/docs/how-it-works/identity.md +0 -56
  56. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +0 -76
  57. package/mufl_code/core/docs/how-it-works/messaging.md +0 -55
  58. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +0 -72
  59. package/mufl_code/core/docs/how-it-works/overview.md +0 -45
  60. package/mufl_code/core/docs/how-it-works/versioning.md +0 -116
  61. package/mufl_code/core/docs/index.md +0 -27
  62. package/mufl_code/core/docs/package-lock.json +0 -3467
  63. package/mufl_code/core/docs/package.json +0 -15
  64. package/mufl_code/core/docs/reference/contributing.md +0 -26
  65. package/mufl_code/core/docs/reference/glossary.md +0 -108
  66. package/mufl_code/core/docs/reference/implementations.md +0 -23
  67. package/mufl_code/core/docs/reference/modules.md +0 -19
  68. package/mufl_code/core/docs/scripts/check-citations.mjs +0 -36
  69. package/mufl_code/core/docs/scripts/check-denylist.mjs +0 -30
  70. package/mufl_code/core/docs/scripts/gen-llms.mjs +0 -32
  71. package/mufl_code/core/docs/workflows/cluster.md +0 -89
  72. package/mufl_code/core/docs/workflows/contact-exchange.md +0 -60
  73. package/mufl_code/core/docs/workflows/contact-restore.md +0 -60
  74. package/mufl_code/core/docs/workflows/control-verbs.md +0 -70
  75. package/mufl_code/core/docs/workflows/index.md +0 -57
  76. package/mufl_code/core/docs/workflows/introductions.md +0 -60
  77. package/mufl_code/core/docs/workflows/messaging.md +0 -75
  78. package/mufl_code/core/docs/workflows/monitoring.md +0 -84
  79. package/mufl_code/core/docs/workflows/notifications.md +0 -109
  80. package/mufl_code/core/release-notes/0.1.md +0 -60
  81. package/mufl_code/core/release-notes/0.3.md +0 -22
  82. package/mufl_code/core/release-notes/0.4.md +0 -97
  83. package/mufl_code/core/tests/README.md +0 -83
  84. package/mufl_code/core/tests/compat/README.md +0 -82
  85. package/mufl_code/core/tests/compat/compat.mjs +0 -245
  86. package/mufl_code/core/tests/compat/compat_peer.mjs +0 -146
  87. package/mufl_code/core/tests/compat/run_compat.sh +0 -96
  88. package/mufl_code/core/tests/corpus.mjs +0 -139
  89. package/mufl_code/core/tests/corpus_actor.mu +0 -242
  90. package/mufl_code/core/tests/fatcompile_actor.mu +0 -52
  91. package/mufl_code/core/tests/mig.mjs +0 -805
  92. package/mufl_code/core/tests/mig_actor.mu +0 -518
  93. package/mufl_code/core/tests/migapp.mjs +0 -431
  94. package/mufl_code/core/tests/migapp_actor.mu +0 -294
  95. package/mufl_code/core/tests/mufl_semantics/run.sh +0 -69
  96. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +0 -47
  97. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +0 -11
  98. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +0 -30
  99. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +0 -19
  100. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +0 -19
  101. package/mufl_code/core/tests/notif.mjs +0 -755
  102. package/mufl_code/core/tests/notif_actor.mu +0 -450
  103. package/mufl_code/core/tests/protocol_container.mm +0 -18
  104. package/mufl_code/core/tests/run.sh +0 -65
  105. package/mufl_code/core/tests/run_corpus.sh +0 -51
  106. package/mufl_code/core/tests/run_fatcompile.sh +0 -40
  107. package/mufl_code/core/tests/run_mig.sh +0 -51
  108. package/mufl_code/core/tests/run_migapp.sh +0 -51
  109. package/mufl_code/core/tests/run_notif.sh +0 -57
  110. package/mufl_code/core/tests/test.mjs +0 -750
  111. package/mufl_code/core/tests/test_actor.mu +0 -564
  112. package/mufl_code/core/tests/test_common.mjs +0 -93
  113. package/mufl_code/core/version.mm +0 -24
  114. package/mufl_code/packet.mu +0 -170
  115. package/mufl_code/protocol_container.mm +0 -18
  116. 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": "ours.network Notifications-as-a-Service — a2a_notifications service packet + WebPush daemon, with a start/stop/status/install-service CLI (ours-notify). Self-provisions identity + VAPID; runs on any host, point DNS at the discovery port.",
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-notify": "dist/cli.js"
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
- "prepare": "scripts/compile-mufl.sh && tsc",
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
- "@adapt-toolkit/sdk": "0.10.12",
31
- "@adapt-toolkit/sdk-native": "0.10.12",
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.3",
40
- "playwright": "^1.61.1",
41
- "typescript": "^5.5.0"
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
- }