@ours.network/notifications 0.1.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 (107) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +231 -0
  3. package/dist/cli.js +446 -0
  4. package/dist/config.js +104 -0
  5. package/dist/daemon.js +353 -0
  6. package/dist/http.js +31 -0
  7. package/dist/log-store.js +48 -0
  8. package/dist/presence.js +157 -0
  9. package/dist/provision.js +29 -0
  10. package/dist/push.js +6 -0
  11. package/dist/web-push-sender.js +41 -0
  12. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  13. package/mufl_code/config.mufl +21 -0
  14. package/mufl_code/core/.github/FUNDING.yml +3 -0
  15. package/mufl_code/core/.github/workflows/docs.yml +39 -0
  16. package/mufl_code/core/.github/workflows/tests.yml +66 -0
  17. package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
  18. package/mufl_code/core/COMPATIBILITY.md +221 -0
  19. package/mufl_code/core/CONTRIBUTING.md +37 -0
  20. package/mufl_code/core/LICENSE +98 -0
  21. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
  22. package/mufl_code/core/README.md +91 -0
  23. package/mufl_code/core/SECURITY.md +40 -0
  24. package/mufl_code/core/TRADEMARKS.md +24 -0
  25. package/mufl_code/core/a2a_capabilities.mm +449 -0
  26. package/mufl_code/core/a2a_cluster.mm +601 -0
  27. package/mufl_code/core/a2a_control.mm +90 -0
  28. package/mufl_code/core/a2a_messaging.mm +4817 -0
  29. package/mufl_code/core/a2a_monitoring.mm +94 -0
  30. package/mufl_code/core/a2a_notification_integration.mm +144 -0
  31. package/mufl_code/core/a2a_notifications.mm +1127 -0
  32. package/mufl_code/core/a2a_protocol.mm +246 -0
  33. package/mufl_code/core/a2a_versions.mm +801 -0
  34. package/mufl_code/core/config.mufl +29 -0
  35. package/mufl_code/core/docs/.vitepress/config.mts +58 -0
  36. package/mufl_code/core/docs/citations.json +688 -0
  37. package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
  38. package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
  39. package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
  40. package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
  41. package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
  42. package/mufl_code/core/docs/guide/index.md +63 -0
  43. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
  44. package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
  45. package/mufl_code/core/docs/how-it-works/identity.md +56 -0
  46. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
  47. package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
  48. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
  49. package/mufl_code/core/docs/how-it-works/overview.md +45 -0
  50. package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
  51. package/mufl_code/core/docs/index.md +27 -0
  52. package/mufl_code/core/docs/package-lock.json +3467 -0
  53. package/mufl_code/core/docs/package.json +15 -0
  54. package/mufl_code/core/docs/reference/contributing.md +26 -0
  55. package/mufl_code/core/docs/reference/glossary.md +108 -0
  56. package/mufl_code/core/docs/reference/implementations.md +23 -0
  57. package/mufl_code/core/docs/reference/modules.md +19 -0
  58. package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
  59. package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
  60. package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
  61. package/mufl_code/core/docs/workflows/cluster.md +89 -0
  62. package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
  63. package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
  64. package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
  65. package/mufl_code/core/docs/workflows/index.md +57 -0
  66. package/mufl_code/core/docs/workflows/introductions.md +60 -0
  67. package/mufl_code/core/docs/workflows/messaging.md +75 -0
  68. package/mufl_code/core/docs/workflows/monitoring.md +84 -0
  69. package/mufl_code/core/docs/workflows/notifications.md +109 -0
  70. package/mufl_code/core/release-notes/0.1.md +60 -0
  71. package/mufl_code/core/release-notes/0.3.md +22 -0
  72. package/mufl_code/core/release-notes/0.4.md +97 -0
  73. package/mufl_code/core/tests/README.md +83 -0
  74. package/mufl_code/core/tests/compat/README.md +82 -0
  75. package/mufl_code/core/tests/compat/compat.mjs +245 -0
  76. package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
  77. package/mufl_code/core/tests/compat/run_compat.sh +96 -0
  78. package/mufl_code/core/tests/corpus.mjs +139 -0
  79. package/mufl_code/core/tests/corpus_actor.mu +242 -0
  80. package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
  81. package/mufl_code/core/tests/mig.mjs +805 -0
  82. package/mufl_code/core/tests/mig_actor.mu +518 -0
  83. package/mufl_code/core/tests/migapp.mjs +431 -0
  84. package/mufl_code/core/tests/migapp_actor.mu +294 -0
  85. package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
  86. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
  87. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
  88. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
  89. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
  90. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
  91. package/mufl_code/core/tests/notif.mjs +755 -0
  92. package/mufl_code/core/tests/notif_actor.mu +450 -0
  93. package/mufl_code/core/tests/protocol_container.mm +18 -0
  94. package/mufl_code/core/tests/run.sh +65 -0
  95. package/mufl_code/core/tests/run_corpus.sh +51 -0
  96. package/mufl_code/core/tests/run_fatcompile.sh +40 -0
  97. package/mufl_code/core/tests/run_mig.sh +51 -0
  98. package/mufl_code/core/tests/run_migapp.sh +51 -0
  99. package/mufl_code/core/tests/run_notif.sh +57 -0
  100. package/mufl_code/core/tests/test.mjs +750 -0
  101. package/mufl_code/core/tests/test_actor.mu +564 -0
  102. package/mufl_code/core/tests/test_common.mjs +93 -0
  103. package/mufl_code/core/version.mm +24 -0
  104. package/mufl_code/packet.mu +170 -0
  105. package/mufl_code/protocol_container.mm +18 -0
  106. package/package.json +43 -0
  107. package/scripts/gen-vapid.mjs +16 -0
@@ -0,0 +1,1127 @@
1
+ // Shared ours notifications library — protocol-level Notifications-as-a-Service.
2
+ //
3
+ // ONE library, TWO halves (the a2a_messaging/a2a_monitoring split-by-state
4
+ // pattern):
5
+ //
6
+ // - CLIENT half (compiled into every consumer): register with a notification
7
+ // service, request per-contact scoped tokens (issue_tokens), export a
8
+ // per-contact handout blob (notify_address_t), send_notification to any
9
+ // handout, relay mark_read.
10
+ // - SERVICE half (exercised only by a notifier packet): registration + token
11
+ // state, post_notification ingest, and the app hooks a host daemon consumes
12
+ // for storage + WebPush egress. A packet "is" a notification service by
13
+ // USING this half and advertising cap_notifications — no node-type enum.
14
+ //
15
+ // Trust model: the payload is NOT end-to-end private — the
16
+ // service reads every payload and delivers plaintext to the device (transport
17
+ // legs are encrypted by the wire and by WebPush itself). Tokens are PER-CONTACT
18
+ // scoped: every token names the one sender ($scope == _str sender_cid) it was
19
+ // issued for, and sender authorization is possession of that scoped token PLUS
20
+ // sender binding (the signature-verified envelope $from must equal $scope), so
21
+ // a stolen token is useless without the named contact's signing key.
22
+ // post_notification arrives as a BARE signed send from a packet the service
23
+ // never met (the invite-leg precedent), NOT over an encrypted channel. There
24
+ // is no anti-spam; per-contact rotation/revocation is the recovery path for a
25
+ // leaked/spammed handout — a leak only ever exposes one relationship's channel.
26
+ //
27
+ // Delivery/storage stay APP-SIDE (the core's architectural rule): this library
28
+ // validates + resolves and hands (notification, bindings) to injected hooks;
29
+ // the host daemon does WebPush HTTP and owns the notification log. mark_read
30
+ // therefore validates the caller and delegates ids (NIL = all) to a hook.
31
+ //
32
+ // Routing: brand-new surface, no legacy clients — every inbound name is
33
+ // LIBRARY-routed ::a2a_notifications::<name> (the receive_file_tx precedent).
34
+ library a2a_notifications loads libraries
35
+ key_storage,
36
+ current_transaction_info,
37
+ encrypted_channel,
38
+ a2a_versions,
39
+ a2a_protocol,
40
+ a2a_capabilities,
41
+ a2a_messaging
42
+ uses transactions
43
+ {
44
+ // Network-visible inbound transaction names (embedded in what peers send).
45
+ register_tx = "::a2a_notifications::register".
46
+ confirm_registration_tx = "::a2a_notifications::confirm_registration".
47
+ update_bindings_tx = "::a2a_notifications::update_bindings".
48
+ rotate_token_tx = "::a2a_notifications::rotate_token".
49
+ unregister_tx = "::a2a_notifications::unregister".
50
+ post_notification_tx = "::a2a_notifications::post_notification".
51
+ mark_read_tx = "::a2a_notifications::mark_read".
52
+ issue_tokens_tx = "::a2a_notifications::issue_tokens".
53
+ set_sender_muted_tx = "::a2a_notifications::set_sender_muted".
54
+ revoke_sender_tokens_tx = "::a2a_notifications::revoke_sender_tokens".
55
+ // Maximum senders per issue_tokens call (V12 batch cap — the client wrapper
56
+ // pages >256 into multiple calls automatically).
57
+ issue_max_senders = 256.
58
+
59
+ // WebPush payload ceiling is ~4KB; oversize posts abort on BOTH sides
60
+ // (sender-side in send_notification, service-side in post_notification).
61
+ payload_max_bytes = 4000.
62
+
63
+ // ---- wire shapes (all versioned; $c/$s signed-artifact idiom) ----------
64
+
65
+ // The per-contact sender token, minted and signed by the SERVICE. One token
66
+ // per (recipient, sender) pair; $token_id is the rotation/revocation handle
67
+ // (revocation is a service-side index lookup, not a signature question).
68
+ // The signature lets a CLIENT verify a handout against the service's pinned
69
+ // keys (check_signature_new_container idiom) — the service itself validates
70
+ // presented tokens by byte-equality against what it stored.
71
+ metadef notify_token_core_t: (
72
+ $version -> int, // 1
73
+ $service_cid -> global_id, // the issuing service (binds token to one N)
74
+ $recipient_cid -> global_id, // whom it notifies
75
+ $token_id -> global_id, // rotation/revocation handle
76
+ $scope -> str, // _str of the SENDER cid this token is issued to
77
+ $iat -> time
78
+ ).
79
+ metadef notify_token_t: ($c -> notify_token_core_t, $s -> crypto_signature).
80
+
81
+ // One WebPush subscription (browser PushSubscription fields).
82
+ metadef webpush_binding_t: (
83
+ $version -> int, // 1
84
+ $binding_id -> str, // app-chosen stable id (e.g. endpoint hash) for replace/remove UX
85
+ $endpoint -> str, // push service URL
86
+ $p256dh -> str, // client public key, base64url
87
+ $auth -> str // auth secret, base64url
88
+ ).
89
+
90
+ // The handout blob R gives to senders ("to notify me: this service, this token").
91
+ metadef notify_address_t: (
92
+ $version -> int, // 1
93
+ $service_cid -> global_id, // bare-send routing target
94
+ $service_name -> str, // display only
95
+ $token -> notify_token_t
96
+ ).
97
+
98
+ // SERVICE-side registration record (core state on N). Registration carries
99
+ // NO token — per-contact tokens live in notify_sender_tokens, minted only
100
+ // by issue_tokens.
101
+ metadef registration_t: (
102
+ $version -> int, // 1
103
+ $recipient_cid -> global_id,
104
+ $bindings -> webpush_binding_t[],
105
+ $created_at -> time
106
+ ).
107
+
108
+ // CLIENT-side view of my registration (core state on R).
109
+ metadef my_registration_t: (
110
+ $service_cid -> global_id,
111
+ $service_name -> str,
112
+ $vapid_pub -> str, // service's VAPID public key (browser subscribe param)
113
+ $bindings -> webpush_binding_t[],
114
+ $created_at -> time
115
+ ).
116
+
117
+ // One posted notification as handed to the service app hook (LOG storage is
118
+ // app-side; the daemon owns read/unread status).
119
+ metadef notification_t: (
120
+ $version -> int, // 1
121
+ $notif_id -> str, // service-stamped (_str of _new_id) — mark_read handle
122
+ $wire_id -> str, // sender-stamped, "" tolerated
123
+ $recipient_cid -> global_id,
124
+ $sender_cid -> global_id, // envelope $from (signature-authenticated; informational)
125
+ $payload -> str, // OPAQUE to the protocol (any payload; service reads it)
126
+ $date -> time
127
+ ).
128
+
129
+ // ---- shared packet state (non-hidden, like contacts/peer_ads) ----------
130
+
131
+ // ---- client half (every consumer) ----
132
+ // My registrations, keyed by service cid.
133
+ my_notify_registrations is (global_id ->> my_registration_t) = (,).
134
+ // Service cids with an in-flight register/rotate awaiting confirm (cleared
135
+ // on confirm; gates which senders may plant a confirm_registration — E9).
136
+ pending_notify_registers is (global_id ->> bool) = (,).
137
+
138
+ // ---- service half (notifier packet) ----
139
+ // Registrations, keyed by recipient cid.
140
+ notify_registrations is (global_id ->> registration_t) = (,).
141
+ // token_id -> recipient cid. Rotation/unregister DELETES entries, so a
142
+ // revoked token dies on the index lookup — this IS revocation.
143
+ notify_token_index is (global_id ->> global_id) = (,).
144
+ // Set by the host at boot (set_vapid_public_key); echoed in every confirm
145
+ // so the client's browser can pushManager.subscribe against it. PUBLIC key
146
+ // only — the VAPID PRIVATE key never enters packet state.
147
+ vapid_public_key is str = "".
148
+
149
+ // ---- service half: per-sender scoped token maps ---------------------------
150
+ // notify_sender_tokens[recipient][sender]: scoped token minted by issue_tokens
151
+ // (E8 idempotent — re-issue keeps existing token; scope == _str sender).
152
+ // The ONLY token store — registration carries no token.
153
+ notify_sender_tokens is (global_id ->> (global_id ->> notify_token_t)) = (,).
154
+ // notify_sender_muted[recipient][sender]: receive-mute flag set by
155
+ // set_sender_muted. Stores FALSE when muted; ABSENT = enabled.
156
+ notify_sender_muted is (global_id ->> (global_id ->> bool)) = (,).
157
+
158
+ // ---- client half: mirror of the per-sender tokens from the last confirm
159
+ // my_notify_contact_tokens[service][sender]: the scoped token the service
160
+ // returned for that sender in the confirm_registration; replaced wholesale
161
+ // from $sender_tokens in each confirm (the confirm is the source of truth).
162
+ my_notify_contact_tokens is (global_id ->> (global_id ->> notify_token_t)) = (,).
163
+
164
+ hidden
165
+ {
166
+ _read_or_abort is (bin->any) = fn (_: bin) { abort "_read_or_abort is unset in a2a_notifications (call a2a_notifications::init)." when TRUE. }
167
+
168
+ // App-injected hooks, unset-aborting defaults (the a2a_messaging::init
169
+ // pattern). Each receives one record and returns the actions to append.
170
+ //
171
+ // on_notification_posted ($notification -> notification_t,
172
+ // $bindings -> webpush_binding_t[]): SERVICE side — a validated post.
173
+ // The daemon appends to its log (status unread) and sends one WebPush
174
+ // per binding. Zero bindings is valid (logged, nothing pushed).
175
+ on_notification_posted is (any -> transaction::action::type[]) = fn (_: any) -> transaction::action::type[] { abort "on_notification_posted hook is unset in a2a_notifications (call a2a_notifications::init)." when TRUE. return []. }
176
+ // on_notifications_marked_read ($recipient_cid, $notif_ids -> str[]+):
177
+ // SERVICE side — $notif_ids NIL means ALL. The daemon updates its log
178
+ // (unknown/foreign ids are ignored there — idempotent).
179
+ on_notifications_marked_read is (any -> transaction::action::type[]) = fn (_: any) -> transaction::action::type[] { abort "on_notifications_marked_read hook is unset in a2a_notifications (call a2a_notifications::init)." when TRUE. return []. }
180
+ // on_unregistered ($recipient_cid): SERVICE side — registration torn
181
+ // down; the daemon may purge that recipient's log.
182
+ on_unregistered is (any -> transaction::action::type[]) = fn (_: any) -> transaction::action::type[] { abort "on_unregistered hook is unset in a2a_notifications (call a2a_notifications::init)." when TRUE. return []. }
183
+ // on_notify_registration ($service_cid, $registration -> my_registration_t,
184
+ // $sender_tokens -> (global_id ->> notify_token_t),
185
+ // $sender_muted -> (global_id ->> bool)):
186
+ // CLIENT side — a confirm landed; the host surfaces $vapid_pub so the
187
+ // browser can create/refresh its WebPush subscription. $sender_tokens/
188
+ // $sender_muted are the full per-contact maps from the confirm so a
189
+ // consumer (the messenger's distribution engine) can diff/mirror them.
190
+ on_notify_registration is (any -> transaction::action::type[]) = fn (_: any) -> transaction::action::type[] { abort "on_notify_registration hook is unset in a2a_notifications (call a2a_notifications::init)." when TRUE. return []. }
191
+ }
192
+
193
+ init = fn (_:(
194
+ $_read_or_abort -> read: (bin->any),
195
+ $on_notification_posted -> posted_cb: (any -> transaction::action::type[]),
196
+ $on_notifications_marked_read -> marked_cb: (any -> transaction::action::type[]),
197
+ $on_unregistered -> unregistered_cb: (any -> transaction::action::type[]),
198
+ $on_notify_registration -> registration_cb: (any -> transaction::action::type[])
199
+ ))
200
+ {
201
+ _read_or_abort -> read.
202
+ on_notification_posted -> posted_cb.
203
+ on_notifications_marked_read -> marked_cb.
204
+ on_unregistered -> unregistered_cb.
205
+ on_notify_registration -> registration_cb.
206
+ }
207
+
208
+ // ---- shared action builders (the a2a_messaging builders) ---------------
209
+ fn _save_state (_) = (transaction::action::return_data ($kind -> $save_state)).
210
+ fn _return_data (payload: any) = (transaction::action::return_data ($kind -> $data, $payload -> payload)).
211
+
212
+ // ---- service-side helpers ----------------------------------------------
213
+
214
+ // Mint a per-contact token for one (recipient, sender) pair: scope is
215
+ // always _str of the sender cid the token is issued to. Detached signature
216
+ // over _value_id (the delegation_cert_t signing idiom). The CALLER stores
217
+ // the notify_sender_tokens slot + index entry.
218
+ fn mint_notify_token_scoped (recipient: global_id, scope: str) -> notify_token_t
219
+ {
220
+ core is notify_token_core_t = (
221
+ $version -> 1,
222
+ $service_cid -> _get_container_id(),
223
+ $recipient_cid -> recipient,
224
+ $token_id -> _new_id "ours notify token",
225
+ $scope -> scope,
226
+ $iat -> (current_transaction_info::get_transaction_time())?
227
+ ).
228
+ return ($c -> core, $s -> key_storage::default_sign (_value_id core)).
229
+ }
230
+
231
+ // The confirm leg every service-side mutator replies with (register /
232
+ // update_bindings / issue_tokens / rotate_token / … — idempotent): this
233
+ // service's VAPID public key, the registration's current bindings, and the
234
+ // full per-sender token + mute maps (the client mirrors them wholesale —
235
+ // re-confirms are idempotent replays), over the already-established
236
+ // encrypted channel.
237
+ fn confirm_actions (recipient: global_id) -> transaction::action::type[]
238
+ {
239
+ reg = notify_registrations recipient.
240
+ abort "No registration to confirm." when reg == NIL.
241
+ sender_tokens_map is (global_id ->> notify_token_t) = (,).
242
+ if (notify_sender_tokens recipient) != NIL { sender_tokens_map -> (notify_sender_tokens recipient)?. }
243
+ sender_muted_map is (global_id ->> bool) = (,).
244
+ if (notify_sender_muted recipient) != NIL { sender_muted_map -> (notify_sender_muted recipient)?. }
245
+ return [
246
+ encrypted_channel::send_encrypted_tx recipient (
247
+ $name -> confirm_registration_tx,
248
+ $targ -> (
249
+ $vapid_pub -> vapid_public_key,
250
+ $bindings -> (reg? $bindings),
251
+ $sender_tokens -> sender_tokens_map,
252
+ $sender_muted -> sender_muted_map,
253
+ $pv -> a2a_versions::wire_version
254
+ )
255
+ )
256
+ ].
257
+ }
258
+
259
+ // ---- CAP-1 gate (SPEC §4, core 0.5.0) -------------------------------------
260
+ // No capability-gated traffic to a contact that positively does NOT
261
+ // advertise the capability. Deny ONLY on positive evidence: the peer
262
+ // piggybacked a NON-EMPTY capability set (0.5.0 invite/restore legs) that
263
+ // lacks core.notifications. Unknown / absent / empty caps PASS — pre-0.5
264
+ // peers and pre-0.5-established contacts keep working (fail-open, aligned
265
+ // with "old peers never crash"; owner-approved interpretation).
266
+ fn peer_lacks_notifications (target: global_id) -> bool
267
+ {
268
+ caps = a2a_messaging::contact_caps target.
269
+ if caps == NIL { return FALSE. }
270
+ if (_count caps?|) == 0 { return FALSE. }
271
+ found is bool = FALSE.
272
+ sc caps? -- ( -> c)
273
+ {
274
+ if c == a2a_capabilities::cap_notifications { found -> TRUE. break. }
275
+ }
276
+ return found != TRUE.
277
+ }
278
+
279
+ // The CAP-1 denial, AS DATA (degrade, never abort user flows): the caller
280
+ // gets a structured $ok/$error result it can render; nothing is sent.
281
+ fn cap1_denial (target: global_id) -> transaction::results::type
282
+ {
283
+ return transaction::success [
284
+ _return_data (
285
+ $ok -> FALSE,
286
+ $error -> (
287
+ $code -> "capability_not_advertised",
288
+ $cap -> a2a_capabilities::cap_notifications,
289
+ $message -> "This contact's app does not advertise notification support (core.notifications), so notifications to it are disabled. Base messaging still works.",
290
+ $peer_cid -> target
291
+ )
292
+ )
293
+ ].
294
+ }
295
+
296
+ // ---- client transactions (user-origin) -----------------------------------
297
+
298
+ // Enroll with a notification service (must already be a contact — the
299
+ // normal invite/introduction machinery; no new connection flow). $bindings
300
+ // may be NIL: register first, add bindings once the browser subscribed
301
+ // (E11 — zero bindings is a valid registration).
302
+ trn notify_register _:($service -> service_ref: str, $bindings -> bindings: webpush_binding_t[]+)
303
+ {
304
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
305
+
306
+ target_id = a2a_messaging::resolve_contact service_ref.
307
+ // CAP-1: registering IS the start of capability-gated traffic — deny
308
+ // as data when the service positively lacks core.notifications.
309
+ if peer_lacks_notifications target_id { return cap1_denial target_id. }
310
+ pending_notify_registers target_id -> TRUE.
311
+
312
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
313
+ return transaction::success [
314
+ encrypted_channel::send_encrypted_tx target_id (
315
+ $name -> register_tx,
316
+ $targ -> ($bindings -> bindings, $pv -> a2a_versions::wire_version)
317
+ ),
318
+ _return_data ($sent_to -> target_id),
319
+ _save_state NIL
320
+ ].
321
+ }).
322
+ }
323
+
324
+ // Replace-all bindings update (the service re-confirms, which refreshes the
325
+ // client copy too). Requires an existing registration with that service.
326
+ trn notify_update_bindings _:($service -> service_ref: str, $bindings -> bindings: webpush_binding_t[])
327
+ {
328
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
329
+
330
+ target_id = a2a_messaging::resolve_contact service_ref.
331
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
332
+
333
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
334
+ return transaction::success [
335
+ encrypted_channel::send_encrypted_tx target_id (
336
+ $name -> update_bindings_tx,
337
+ $targ -> ($bindings -> bindings, $pv -> a2a_versions::wire_version)
338
+ ),
339
+ _return_data ($sent_to -> target_id)
340
+ ].
341
+ }).
342
+ }
343
+
344
+ // Replace a single sender's scoped token OR all scoped tokens.
345
+ // $contact present → rotate ONLY that sender's slot; $contact NIL →
346
+ // rotate ALL scoped slots (panic button). Recovery path for a leaked token
347
+ // or wholesale reset.
348
+ trn notify_rotate_token _:($service -> service_ref: str, $contact -> contact_ref: str+)
349
+ {
350
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
351
+
352
+ target_id = a2a_messaging::resolve_contact service_ref.
353
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
354
+ pending_notify_registers target_id -> TRUE.
355
+
356
+ // Resolve the optional $contact before entering the execute_transaction lambda
357
+ // (resolve_contact reads state; resolving inside the callback is not needed).
358
+ // sender_id NIL → rotate-all path on the service; non-NIL → per-sender path.
359
+ sender_id is global_id+ = NIL.
360
+ if contact_ref != NIL { sender_id -> a2a_messaging::resolve_contact (contact_ref?). }
361
+
362
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
363
+ // Build $targ: $sender present → service picks per-sender path;
364
+ // absent (empty tuple) → service picks rotate-all path.
365
+ targ is any = ($pv -> a2a_versions::wire_version).
366
+ if sender_id != NIL { targ -> ($sender -> sender_id?, $pv -> a2a_versions::wire_version). }
367
+ return transaction::success [
368
+ encrypted_channel::send_encrypted_tx target_id (
369
+ $name -> rotate_token_tx,
370
+ $targ -> targ
371
+ ),
372
+ _return_data ($sent_to -> target_id),
373
+ _save_state NIL
374
+ ].
375
+ }).
376
+ }
377
+
378
+ // Tell the service to mute or unmute notifications from a given sender.
379
+ // $muted TRUE → block; FALSE → unblock (delete entry — absent = enabled).
380
+ // Runtime-only: no token change, no re-index. Service re-confirms so alice's state
381
+ // mirror stays coherent. $contact is the sender's global_id (cid string accepted by SDK).
382
+ trn notify_set_sender_muted _:($service -> service_ref: str, $contact -> contact_id: global_id, $muted -> muted: bool)
383
+ {
384
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
385
+
386
+ target_id = a2a_messaging::resolve_contact service_ref.
387
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
388
+
389
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
390
+ return transaction::success [
391
+ encrypted_channel::send_encrypted_tx target_id (
392
+ $name -> set_sender_muted_tx,
393
+ $targ -> ($sender -> contact_id, $muted -> muted, $pv -> a2a_versions::wire_version)
394
+ ),
395
+ _return_data ($sent_to -> target_id),
396
+ _save_state NIL
397
+ ].
398
+ }).
399
+ }
400
+
401
+ // Tell the service to revoke the scoped tokens for each listed contact — no
402
+ // re-mint (revoke-without-replace). Service deletes each sender's
403
+ // index entry and notify_sender_tokens slot; posts against those token_ids abort at
404
+ // step 3 (index lookup). Empty list / unknown-sender slots: tolerated (E4/E8).
405
+ trn notify_revoke_contact_tokens _:($service -> service_ref: str, $contacts -> contacts: global_id[])
406
+ {
407
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
408
+
409
+ target_id = a2a_messaging::resolve_contact service_ref.
410
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
411
+
412
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
413
+ return transaction::success [
414
+ encrypted_channel::send_encrypted_tx target_id (
415
+ $name -> revoke_sender_tokens_tx,
416
+ $targ -> ($senders -> contacts, $pv -> a2a_versions::wire_version)
417
+ ),
418
+ _return_data ($sent_to -> target_id),
419
+ _save_state NIL
420
+ ].
421
+ }).
422
+ }
423
+
424
+ // Relay a mark-read to the service's daemon log. $notif_ids NIL means mark
425
+ // ALL my notifications read — the default an app fires on open. "Dismiss"
426
+ // == mark_read in v1 (one verb, no separate dismissed state).
427
+ trn notify_mark_read _:($service -> service_ref: str, $notif_ids -> notif_ids: str[]+)
428
+ {
429
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
430
+
431
+ target_id = a2a_messaging::resolve_contact service_ref.
432
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
433
+
434
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
435
+ return transaction::success [
436
+ encrypted_channel::send_encrypted_tx target_id (
437
+ $name -> mark_read_tx,
438
+ $targ -> ($notif_ids -> notif_ids, $pv -> a2a_versions::wire_version)
439
+ ),
440
+ _return_data ($sent_to -> target_id)
441
+ ].
442
+ }).
443
+ }
444
+
445
+ // Full teardown with a service: tell it to drop my registration, clear my
446
+ // local copy. (Revoke-without-replace == unregister; rotation is the
447
+ // keep-registered recovery path.)
448
+ trn notify_unregister _:($service -> service_ref: str)
449
+ {
450
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
451
+
452
+ target_id = a2a_messaging::resolve_contact service_ref.
453
+ if (my_notify_registrations target_id) != NIL { delete my_notify_registrations target_id. }
454
+ if (pending_notify_registers target_id) != NIL { delete pending_notify_registers target_id. }
455
+ // The contact-token mirror goes with the registration: the service
456
+ // purges its token store on unregister, so a kept mirror would be a
457
+ // stale-token source (e.g. a distribution engine rebuilding handout
458
+ // blobs from it after the tokens died at the service).
459
+ if (my_notify_contact_tokens target_id) != NIL { delete my_notify_contact_tokens target_id. }
460
+
461
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
462
+ return transaction::success [
463
+ encrypted_channel::send_encrypted_tx target_id (
464
+ $name -> unregister_tx,
465
+ $targ -> ($pv -> a2a_versions::wire_version,)
466
+ ),
467
+ _return_data ($sent_to -> target_id),
468
+ _save_state NIL
469
+ ].
470
+ }).
471
+ }
472
+
473
+ // Ask the service to mint scoped tokens for each contact in $contacts. The
474
+ // service cap is issue_max_senders (256) per call; this wrapper pages the
475
+ // list into batches automatically so callers never need to handle paging.
476
+ // Re-issuing a contact the service already has a token for is idempotent (E8).
477
+ // Requires an existing registration with the service.
478
+ trn notify_issue_tokens _:($service -> service_ref: str, $contacts -> contacts: global_id[])
479
+ {
480
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
481
+
482
+ target_id = a2a_messaging::resolve_contact service_ref.
483
+ abort "No notification registration with that service." when (my_notify_registrations target_id) == NIL.
484
+
485
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
486
+ // Page the contact list into batches of issue_max_senders.
487
+ batch is global_id[] = [].
488
+ actions is transaction::action::type[] = [].
489
+ sc contacts -- ( -> sender)
490
+ {
491
+ batch (_count batch|) -> sender.
492
+ if (_count batch) == issue_max_senders
493
+ {
494
+ actions (_count actions|) -> encrypted_channel::send_encrypted_tx target_id (
495
+ $name -> issue_tokens_tx,
496
+ $targ -> ($senders -> batch, $pv -> a2a_versions::wire_version)
497
+ ).
498
+ batch -> [].
499
+ }
500
+ }
501
+ if (_count batch) > 0
502
+ {
503
+ actions (_count actions|) -> encrypted_channel::send_encrypted_tx target_id (
504
+ $name -> issue_tokens_tx,
505
+ $targ -> ($senders -> batch, $pv -> a2a_versions::wire_version)
506
+ ).
507
+ }
508
+ actions (_count actions|) -> _return_data ($sent_to -> target_id).
509
+ actions (_count actions|) -> _save_state NIL.
510
+ return transaction::success actions.
511
+ }).
512
+ }
513
+
514
+ // Export the per-contact handout blob for one contact ("to notify me: this
515
+ // service, this contact's scoped token"). The token must already exist in
516
+ // my_notify_contact_tokens (i.e. a confirm after notify_issue_tokens has
517
+ // landed). Distribution is out of protocol — it rides ordinary
518
+ // send_message/send_file or the messenger's distribution engine.
519
+ fn build_notify_address (service_ref: str, contact_ref: str) -> bin
520
+ {
521
+ target_id = a2a_messaging::resolve_contact service_ref.
522
+ reg = my_notify_registrations target_id.
523
+ abort "No notification registration with that service." when reg == NIL.
524
+ contact_id = a2a_messaging::resolve_contact contact_ref.
525
+ outer = my_notify_contact_tokens target_id.
526
+ abort "No scoped token for that contact — call notify_issue_tokens first." when outer == NIL.
527
+ tok = (outer?) contact_id.
528
+ abort "No scoped token for that contact — call notify_issue_tokens first." when tok == NIL.
529
+ addr is notify_address_t = (
530
+ $version -> 1,
531
+ $service_cid -> (reg? $service_cid),
532
+ $service_name -> (reg? $service_name),
533
+ $token -> tok?
534
+ ).
535
+ return _write addr.
536
+ }
537
+
538
+ trn readonly export_notify_address _:($service -> service_ref: str, $contact -> contact_ref: str)
539
+ {
540
+ blob = build_notify_address service_ref contact_ref.
541
+ return ($blob -> blob, $service_cid -> (_str (a2a_messaging::resolve_contact service_ref))).
542
+ }
543
+
544
+ // THE parallel of send_message, against a handout instead of a contact:
545
+ // parse the blob, stamp a wire_id from the shared _new_id namespace, and
546
+ // emit ONE BARE signed send of post_notification to the blob's service —
547
+ // deliberately NOT send_encrypted_tx (the sender never has a channel with
548
+ // the service; the token is the sole authorization) and deliberately NO
549
+ // monitoring copy (D-4: the forced-monitoring contract covers messages;
550
+ // notifications are wake-up signals). Fire-and-forget: no response leg.
551
+ // Parse and structurally validate a handout. The notification service is
552
+ // the final authorization authority (live token index + exact stored token
553
+ // + sender scope); a sender intentionally need not be its contact and thus
554
+ // does not necessarily possess service keys for local signature checking.
555
+ fn decode_notify_address (address_blob: bin) -> notify_address_t
556
+ {
557
+ addr = (_read_or_abort address_blob) safe notify_address_t.
558
+ abort "Unsupported notify address version." when (addr $version) != 1.
559
+ abort "Unsupported notify token version." when (addr $token $c $version) != 1.
560
+ abort "Notify address service does not match its token." when (addr $service_cid) != (addr $token $c $service_cid).
561
+ return addr.
562
+ }
563
+
564
+ // Reusable fire-and-forget builder. The caller supplies wire_id so a post
565
+ // produced by send_message carries that message's actual stable id.
566
+ fn notification_send_action (address_blob: bin, payload: str, wire_id: str) -> transaction::action::type
567
+ {
568
+ addr = decode_notify_address address_blob.
569
+ abort "Notification payload exceeds " + (_str payload_max_bytes) + " bytes." when (_strlen payload) > payload_max_bytes.
570
+ return transaction::action::send (addr $service_cid) (
571
+ $name -> post_notification_tx,
572
+ $targ -> (
573
+ $token -> (addr $token),
574
+ $payload -> payload,
575
+ $wire_id -> wire_id,
576
+ $pv -> a2a_versions::wire_version
577
+ )
578
+ ).
579
+ }
580
+
581
+ trn send_notification _:($address -> address_blob: bin, $payload -> payload: str)
582
+ {
583
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
584
+
585
+ wire_id = _str (_new_id "ours notification").
586
+ addr = decode_notify_address address_blob.
587
+ return transaction::success [
588
+ notification_send_action address_blob payload wire_id,
589
+ _return_data ($sent_to -> (addr $service_cid), $wire_id -> wire_id)
590
+ ].
591
+ }
592
+
593
+ // ---- service transactions -------------------------------------------------
594
+
595
+ // Host-fired at daemon boot: the VAPID PUBLIC key echoed in every confirm
596
+ // (the browser needs it as applicationServerKey). The PRIVATE key stays in
597
+ // the daemon's env/file — it must never enter packet state.
598
+ trn set_vapid_public_key _:($key -> key: str)
599
+ {
600
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
601
+ vapid_public_key -> key.
602
+ return transaction::success [
603
+ _return_data ($ok -> TRUE),
604
+ _save_state NIL
605
+ ].
606
+ }
607
+
608
+ // SERVICE inbound: enroll the channel-authenticated sender. Registration
609
+ // mints NO token — per-contact scoped tokens are minted exclusively by
610
+ // issue_tokens. Re-register is idempotent recovery (E8): existing scoped
611
+ // tokens stay untouched (already-distributed handouts stay valid) and
612
+ // bindings are replaced only when provided; the confirm is re-sent either way.
613
+ fn handle_register (args: any) -> transaction::results::type
614
+ {
615
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
616
+ encrypted_channel::check_encrypted_or_abort().
617
+
618
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
619
+ bindings is webpush_binding_t[] = [].
620
+ if (args $bindings) != NIL { bindings -> (args $bindings) safe (webpush_binding_t[]). }
621
+
622
+ existing = notify_registrations recipient.
623
+ if existing != NIL
624
+ {
625
+ // E8: replace bindings only when the caller sent some. Registration
626
+ // mints NOTHING — per-contact tokens come only from issue_tokens.
627
+ if (args $bindings) != NIL
628
+ {
629
+ notify_registrations recipient -> (
630
+ $version -> (existing? $version),
631
+ $recipient_cid -> (existing? $recipient_cid),
632
+ $bindings -> bindings,
633
+ $created_at -> (existing? $created_at)
634
+ ).
635
+ }
636
+ }
637
+ else
638
+ {
639
+ notify_registrations recipient -> (
640
+ $version -> 1,
641
+ $recipient_cid -> recipient,
642
+ $bindings -> bindings,
643
+ $created_at -> (current_transaction_info::get_transaction_time())?
644
+ ).
645
+ }
646
+
647
+ actions is transaction::action::type[] = confirm_actions recipient.
648
+ actions (_count actions|) -> _save_state NIL.
649
+ return transaction::success actions.
650
+ }
651
+
652
+ // SERVICE inbound: replace-all bindings for a registered sender; re-confirm.
653
+ fn handle_update_bindings (args: any) -> transaction::results::type
654
+ {
655
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
656
+ encrypted_channel::check_encrypted_or_abort().
657
+
658
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
659
+ existing = notify_registrations recipient.
660
+ abort "No notification registration for this sender." when existing == NIL.
661
+
662
+ bindings is webpush_binding_t[] = [].
663
+ if (args $bindings) != NIL { bindings -> (args $bindings) safe (webpush_binding_t[]). }
664
+ notify_registrations recipient -> (
665
+ $version -> (existing? $version),
666
+ $recipient_cid -> (existing? $recipient_cid),
667
+ $bindings -> bindings,
668
+ $created_at -> (existing? $created_at)
669
+ ).
670
+
671
+ actions is transaction::action::type[] = confirm_actions recipient.
672
+ actions (_count actions|) -> _save_state NIL.
673
+ return transaction::success actions.
674
+ }
675
+
676
+ // SERVICE inbound: mint (or re-use) a scoped token for each requested sender
677
+ // cid and return the current per-sender map in the confirm.
678
+ //
679
+ // $senders: global_id[]+ — non-empty, max issue_max_senders (batch cap).
680
+ //
681
+ // Edge cases:
682
+ // E8: re-issue for an already-known sender keeps the existing token bytes
683
+ // unchanged (idempotent — test proves byte-stability across repeat calls).
684
+ // V11: empty $senders → abort (the type is non-empty; no partial mints).
685
+ // V12: >issue_max_senders entries → abort; client wrapper pages for you.
686
+ // The handler does NOT check whether each sender is a contact of either side —
687
+ // that is intentional (V1: service cannot know R's contact set; any cid R
688
+ // names is eligible for a scoped token).
689
+ fn handle_issue_tokens (args: any) -> transaction::results::type
690
+ {
691
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
692
+ encrypted_channel::check_encrypted_or_abort().
693
+
694
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
695
+ abort "No notification registration for this sender." when (notify_registrations recipient) == NIL.
696
+
697
+ senders is global_id[] = (args $senders) safe (global_id[]).
698
+ abort "issue_tokens requires at least one sender ($senders must be non-empty — V11)." when (_count senders) == 0.
699
+ abort "issue_tokens batch exceeds the " + (_str issue_max_senders) + "-sender cap — V12." when (_count senders) > issue_max_senders.
700
+
701
+ // E8: load existing per-sender map for this recipient (may be empty on
702
+ // the first call).
703
+ existing_map is (global_id ->> notify_token_t) = (,).
704
+ if (notify_sender_tokens recipient) != NIL { existing_map -> (notify_sender_tokens recipient)?. }
705
+
706
+ sc senders -- ( -> sender)
707
+ {
708
+ // Only mint when there is no existing token for this sender.
709
+ if (existing_map sender) == NIL
710
+ {
711
+ token = mint_notify_token_scoped recipient (_str sender).
712
+ existing_map sender -> token.
713
+ notify_token_index (token $c $token_id) -> recipient.
714
+ }
715
+ }
716
+ notify_sender_tokens recipient -> existing_map.
717
+
718
+ actions is transaction::action::type[] = confirm_actions recipient.
719
+ actions (_count actions|) -> _save_state NIL.
720
+ return transaction::success actions.
721
+ }
722
+
723
+ // SERVICE inbound: atomically replace tokens — two paths:
724
+ // · $sender present → per-sender path: rotate ONLY that sender's scoped
725
+ // token; old token_id dies on index delete; fresh scoped token minted / stored /
726
+ // indexed in the same transaction. Other senders untouched.
727
+ // · $sender absent/NIL → rotate-all (panic button): every scoped slot in
728
+ // notify_sender_tokens. Keys collected first to avoid mutation-while-iterating;
729
+ // each old index entry deleted and new token minted / stored / indexed.
730
+ fn handle_rotate_token (args: any) -> transaction::results::type
731
+ {
732
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
733
+ encrypted_channel::check_encrypted_or_abort().
734
+
735
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
736
+ existing = notify_registrations recipient.
737
+ abort "No notification registration for this sender." when existing == NIL.
738
+
739
+ sender is global_id+ = NIL.
740
+ if (args $sender) != NIL { sender -> (args $sender) safe global_id. }
741
+
742
+ if sender != NIL
743
+ {
744
+ // Per-sender path: rotate exactly one scoped token atomically.
745
+ sender_cid = sender?.
746
+ existing_map is (global_id ->> notify_token_t) = (,).
747
+ if (notify_sender_tokens recipient) != NIL { existing_map -> (notify_sender_tokens recipient)?. }
748
+ old_tok = existing_map sender_cid.
749
+ abort "No scoped token for this sender." when old_tok == NIL.
750
+ delete notify_token_index (old_tok? $c $token_id).
751
+ new_tok = mint_notify_token_scoped recipient (_str sender_cid).
752
+ existing_map sender_cid -> new_tok.
753
+ notify_sender_tokens recipient -> existing_map.
754
+ notify_token_index (new_tok $c $token_id) -> recipient.
755
+ }
756
+ else
757
+ {
758
+ // Rotate-all path (Q9 panic button): every scoped slot. Keys are
759
+ // collected first (two-pass — avoid mutation-while-iterating the
760
+ // map), then each slot rotates atomically. No scoped tokens yet →
761
+ // no-op rotate; the confirm still replays the (empty) maps.
762
+ if (notify_sender_tokens recipient) != NIL
763
+ {
764
+ existing_map is (global_id ->> notify_token_t) = (,).
765
+ existing_map -> (notify_sender_tokens recipient)?.
766
+ sender_keys is global_id[] = [].
767
+ sc existing_map -- (s -> _) { sender_keys (_count sender_keys|) -> s. }
768
+ sc sender_keys -- ( -> s)
769
+ {
770
+ old_tok = existing_map s.
771
+ if old_tok != NIL
772
+ {
773
+ delete notify_token_index (old_tok? $c $token_id).
774
+ new_tok = mint_notify_token_scoped recipient (_str s).
775
+ existing_map s -> new_tok.
776
+ notify_token_index (new_tok $c $token_id) -> recipient.
777
+ }
778
+ }
779
+ notify_sender_tokens recipient -> existing_map.
780
+ }
781
+ }
782
+
783
+ actions is transaction::action::type[] = confirm_actions recipient.
784
+ actions (_count actions|) -> _save_state NIL.
785
+ return transaction::success actions.
786
+ }
787
+
788
+ // SERVICE inbound: toggle the receive-mute flag for a named sender:
789
+ // $muted TRUE → write notify_sender_muted[recipient][sender] -> FALSE (the map
790
+ // stores FALSE when receive-disabled — present entry = muted).
791
+ // $muted FALSE → DELETE the entry — absent = enabled, keeps state minimal.
792
+ // Runtime-only: no token change, no re-index. Replies confirm so the client
793
+ // mirror stays coherent. Gate: registered-recipient only (shared gate pattern).
794
+ fn handle_set_sender_muted (args: any) -> transaction::results::type
795
+ {
796
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
797
+ encrypted_channel::check_encrypted_or_abort().
798
+
799
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
800
+ abort "No notification registration for this sender." when (notify_registrations recipient) == NIL.
801
+
802
+ sender = (args $sender) safe global_id.
803
+ muted = (args $muted) safe bool.
804
+
805
+ inner is (global_id ->> bool) = (,).
806
+ if (notify_sender_muted recipient) != NIL { inner -> (notify_sender_muted recipient)?. }
807
+
808
+ if muted == TRUE
809
+ {
810
+ // Mute: store FALSE (= receive-disabled map semantics;
811
+ // present entry = muted, absent = enabled — keeps state minimal).
812
+ inner sender -> FALSE.
813
+ }
814
+ else
815
+ {
816
+ // Unmute: delete entry — absent = enabled (minimal-state rule).
817
+ if (inner sender) != NIL { delete inner sender. }
818
+ }
819
+ notify_sender_muted recipient -> inner.
820
+
821
+ actions is transaction::action::type[] = confirm_actions recipient.
822
+ actions (_count actions|) -> _save_state NIL.
823
+ return transaction::success actions.
824
+ }
825
+
826
+ // SERVICE inbound: revoke the scoped tokens for each listed sender — no
827
+ // re-mint (revoke-without-replace). For each sender in $senders:
828
+ // delete its token_id from notify_token_index (posts against those token_ids
829
+ // abort at step 3) and remove its slot from notify_sender_tokens.
830
+ // Re-confirms once after all senders processed. Idempotent: unknown senders
831
+ // and absent slots are tolerated — delete of absent = no-op (E4/E8).
832
+ fn handle_revoke_sender_tokens (args: any) -> transaction::results::type
833
+ {
834
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
835
+ encrypted_channel::check_encrypted_or_abort().
836
+
837
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
838
+ abort "No notification registration for this sender." when (notify_registrations recipient) == NIL.
839
+
840
+ senders is global_id[] = (args $senders) safe (global_id[]).
841
+
842
+ existing_map is (global_id ->> notify_token_t) = (,).
843
+ if (notify_sender_tokens recipient) != NIL { existing_map -> (notify_sender_tokens recipient)?. }
844
+
845
+ sc senders -- ( -> sender)
846
+ {
847
+ old_tok = existing_map sender.
848
+ if old_tok != NIL
849
+ {
850
+ // Revoke: drop index entry (posts using this token_id abort at step
851
+ // 3) and remove the sender slot (no re-mint).
852
+ delete notify_token_index (old_tok? $c $token_id).
853
+ delete existing_map sender.
854
+ }
855
+ // Unknown sender: no-op (delete of absent slot tolerated — E4/E8).
856
+ }
857
+ notify_sender_tokens recipient -> existing_map.
858
+
859
+ actions is transaction::action::type[] = confirm_actions recipient.
860
+ actions (_count actions|) -> _save_state NIL.
861
+ return transaction::success actions.
862
+ }
863
+
864
+ // SERVICE inbound: full teardown for a registered sender — registration,
865
+ // every scoped token (slot + index entry) and the mute map removed, then
866
+ // the hook lets the daemon purge its log (hook actions before the save,
867
+ // the remove_contact composition). Re-register + issue_tokens starts from
868
+ // a clean slate (old handouts die at the index lookup — E4).
869
+ fn handle_unregister (args: any) -> transaction::results::type
870
+ {
871
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
872
+ encrypted_channel::check_encrypted_or_abort().
873
+
874
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
875
+ existing = notify_registrations recipient.
876
+ abort "No notification registration for this sender." when existing == NIL.
877
+
878
+ // Revoke every scoped token: two-pass (collect keys, then delete) to
879
+ // avoid mutation-while-iterating.
880
+ if (notify_sender_tokens recipient) != NIL
881
+ {
882
+ token_map is (global_id ->> notify_token_t) = (notify_sender_tokens recipient)?.
883
+ sender_keys is global_id[] = [].
884
+ sc token_map -- (s -> _) { sender_keys (_count sender_keys|) -> s. }
885
+ sc sender_keys -- ( -> s)
886
+ {
887
+ tok = token_map s.
888
+ if tok != NIL { delete notify_token_index (tok? $c $token_id). }
889
+ }
890
+ delete notify_sender_tokens recipient.
891
+ }
892
+ if (notify_sender_muted recipient) != NIL { delete notify_sender_muted recipient. }
893
+ delete notify_registrations recipient.
894
+
895
+ actions is transaction::action::type[] = [].
896
+ sc on_unregistered ($recipient_cid -> recipient) -- ( -> a)
897
+ {
898
+ actions (_count actions|) -> a.
899
+ }
900
+ actions (_count actions|) -> _save_state NIL.
901
+ return transaction::success actions.
902
+ }
903
+
904
+ // SERVICE inbound: a registered sender marks its notifications read. The
905
+ // ids (or NIL = ALL) pass straight through to the daemon hook — the daemon
906
+ // updates its log and ignores non-matching ids (E7, idempotent). The hook
907
+ // only ever receives the CALLER's own recipient_cid, so one recipient can
908
+ // never mark another's notifications.
909
+ fn handle_mark_read (args: any) -> transaction::results::type
910
+ {
911
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
912
+ encrypted_channel::check_encrypted_or_abort().
913
+
914
+ recipient = current_transaction_info::get_external_envelope_or_abort() $from.
915
+ abort "No notification registration for this sender." when (notify_registrations recipient) == NIL.
916
+
917
+ notif_ids is str[]+ = NIL.
918
+ if (args $notif_ids) != NIL { notif_ids -> (args $notif_ids) safe (str[]). }
919
+
920
+ return transaction::success (on_notifications_marked_read (
921
+ $recipient_cid -> recipient,
922
+ $notif_ids -> notif_ids
923
+ )).
924
+ }
925
+
926
+ // CLIENT inbound: a service confirmed (or refreshed) my registration. Only
927
+ // a service I am PENDING with or ALREADY registered with may plant one —
928
+ // an unsolicited confirm from any other contact aborts (E9).
929
+ fn handle_confirm_registration (args: any) -> transaction::results::type
930
+ {
931
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
932
+ encrypted_channel::check_encrypted_or_abort().
933
+
934
+ sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
935
+ abort "Unsolicited registration confirm." when (pending_notify_registers sender_id) == NIL && (my_notify_registrations sender_id) == NIL.
936
+
937
+ vapid_pub = (args $vapid_pub) safe str.
938
+ bindings is webpush_binding_t[] = [].
939
+ if (args $bindings) != NIL { bindings -> (args $bindings) safe (webpush_binding_t[]). }
940
+
941
+ service_name is str = "".
942
+ c = a2a_messaging::contacts sender_id.
943
+ if c != NIL { service_name -> c? $name. }
944
+
945
+ myreg is my_registration_t = (
946
+ $service_cid -> sender_id,
947
+ $service_name -> service_name,
948
+ $vapid_pub -> vapid_pub,
949
+ $bindings -> bindings,
950
+ $created_at -> (current_transaction_info::get_transaction_time())?
951
+ ).
952
+ my_notify_registrations sender_id -> myreg.
953
+ if (pending_notify_registers sender_id) != NIL { delete pending_notify_registers sender_id. }
954
+
955
+ // Replace the contact-token mirror from the confirm wholesale — the
956
+ // confirm is the single source of truth; re-confirms are idempotent
957
+ // replays. The mute map is passed through to the hook so a consumer
958
+ // (e.g. the messenger's distribution engine) can mirror it too.
959
+ sender_tokens_map is (global_id ->> notify_token_t) = (,).
960
+ if (args $sender_tokens) != NIL
961
+ {
962
+ sender_tokens_map -> (args $sender_tokens) safe (global_id ->> notify_token_t).
963
+ }
964
+ my_notify_contact_tokens sender_id -> sender_tokens_map.
965
+ sender_muted_map is (global_id ->> bool) = (,).
966
+ if (args $sender_muted) != NIL
967
+ {
968
+ sender_muted_map -> (args $sender_muted) safe (global_id ->> bool).
969
+ }
970
+
971
+ actions is transaction::action::type[] = [].
972
+ sc on_notify_registration ($service_cid -> sender_id, $registration -> myreg,
973
+ $sender_tokens -> sender_tokens_map, $sender_muted -> sender_muted_map) -- ( -> a)
974
+ {
975
+ actions (_count actions|) -> a.
976
+ }
977
+ actions (_count actions|) -> _save_state NIL.
978
+ return transaction::success actions.
979
+ }
980
+
981
+ // SERVICE inbound: the notification ingest. The ONE inbound that accepts a
982
+ // BARE signed send (origin external, NO check_encrypted_or_abort by
983
+ // design): the sender is typically a packet this service never met, and the
984
+ // token is the sole authorization. Validation order matters (abort on first
985
+ // failure, mutate nothing):
986
+ // 1. parse-safe + version 2. minted by THIS service 3. live index
987
+ // entry matching $recipient_cid (rotation/revocation/unregister deletes
988
+ // entries, so revocation is a state lookup) 4. sender binding — the
989
+ // signature-verified envelope $from must equal the token's $scope
990
+ // (evaluated before byte-equality: mufl has no reverse _str() to recover
991
+ // a global_id from the scope string; observably equivalent to the intended
992
+ // order) 5. byte-equality vs the STORED token in
993
+ // notify_sender_tokens[recipient][sender] (_value_id) — forging any
994
+ // field, $scope included, requires possessing the exact minted artifact;
995
+ // no signature re-verification is needed because we compare against what
996
+ // we ourselves stored 6. mute check (FALSE stored = muted, absent =
997
+ // enabled) 7. payload cap.
998
+ // All checks precede hook/store writes. Fire-and-forget: no reply
999
+ // leg — an aborted post is invisible to the sender (mute is unprobeable).
1000
+ fn handle_post_notification (args: any) -> transaction::results::type
1001
+ {
1002
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
1003
+
1004
+ sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
1005
+
1006
+ token = (args $token) safe notify_token_t.
1007
+ abort "Unsupported notify token version." when (token $c $version) != 1.
1008
+ abort "Token was not minted by this service." when (token $c $service_cid) != _get_container_id().
1009
+ indexed = notify_token_index (token $c $token_id).
1010
+ abort "Unknown or revoked notification token." when indexed == NIL.
1011
+ abort "Token recipient does not match its index entry." when indexed? != (token $c $recipient_cid).
1012
+ reg = notify_registrations (token $c $recipient_cid).
1013
+ abort "No registration for the token's recipient." when reg == NIL.
1014
+
1015
+ // Sender binding (Q1 strict): envelope $from must equal the token's
1016
+ // $scope. A token with any other scope — including the empty string —
1017
+ // is not bound to this sender and dies here.
1018
+ abort "Token is not bound to this sender." when (token $c $scope) != _str sender_id.
1019
+ // Byte-equality vs notify_sender_tokens[recipient][sender]. Absent
1020
+ // outer map or absent sender slot both abort (never-issued or revoked).
1021
+ scoped_outer = notify_sender_tokens (token $c $recipient_cid).
1022
+ abort "Token is not bound to this sender." when scoped_outer == NIL.
1023
+ scoped_tok_entry = (scoped_outer?) sender_id.
1024
+ abort "Token is not bound to this sender." when scoped_tok_entry == NIL.
1025
+ abort "Presented token does not match the stored registration." when (_value_id token) != (_value_id scoped_tok_entry?).
1026
+ // Mute check: FALSE stored = muted; absent = enabled.
1027
+ muted_outer = notify_sender_muted (token $c $recipient_cid).
1028
+ if muted_outer != NIL
1029
+ {
1030
+ muted_entry = muted_outer? sender_id.
1031
+ abort "Notifications from this sender are disabled." when muted_entry != NIL.
1032
+ }
1033
+
1034
+ payload = (args $payload) safe str.
1035
+ abort "Notification payload exceeds " + (_str payload_max_bytes) + " bytes." when (_strlen payload) > payload_max_bytes.
1036
+
1037
+ wire_id is str = "".
1038
+ if (args $wire_id) != NIL { wire_id -> (args $wire_id) safe str. }
1039
+
1040
+ notification is notification_t = (
1041
+ $version -> 1,
1042
+ $notif_id -> _str (_new_id "ours notif"),
1043
+ $wire_id -> wire_id,
1044
+ $recipient_cid -> (token $c $recipient_cid),
1045
+ $sender_cid -> sender_id,
1046
+ $payload -> payload,
1047
+ $date -> (current_transaction_info::get_transaction_time())?
1048
+ ).
1049
+ return transaction::success (on_notification_posted (
1050
+ $notification -> notification,
1051
+ $bindings -> (reg? $bindings)
1052
+ )).
1053
+ }
1054
+
1055
+ // Inbound trn stubs (declared AFTER their handlers — define-before-use).
1056
+ trn register args: any { return handle_register args. }
1057
+ trn update_bindings args: any { return handle_update_bindings args. }
1058
+ trn rotate_token args: any { return handle_rotate_token args. }
1059
+ trn unregister args: any { return handle_unregister args. }
1060
+ trn mark_read args: any { return handle_mark_read args. }
1061
+ trn confirm_registration args: any { return handle_confirm_registration args. }
1062
+ trn post_notification args: any { return handle_post_notification args. }
1063
+ trn issue_tokens args: any { return handle_issue_tokens args. }
1064
+ trn set_sender_muted args: any { return handle_set_sender_muted args. }
1065
+ trn revoke_sender_tokens args: any { return handle_revoke_sender_tokens args. }
1066
+
1067
+ // ---- upgrade: state export / import helpers ------------------------------
1068
+ // NOT transactions: each app's export_state/import_state composes these with
1069
+ // its other state (the export_core_state contract). No secret material lives
1070
+ // in either half by construction — the VAPID private key never enters packet
1071
+ // state, and tokens are bearer artifacts the state must keep anyway.
1072
+
1073
+ fn export_notify_state (_) -> any
1074
+ {
1075
+ return (
1076
+ $my_notify_registrations -> my_notify_registrations,
1077
+ $notify_registrations -> notify_registrations,
1078
+ $notify_token_index -> notify_token_index,
1079
+ $vapid_public_key -> vapid_public_key,
1080
+ $notify_sender_tokens -> notify_sender_tokens,
1081
+ $notify_sender_muted -> notify_sender_muted,
1082
+ $my_notify_contact_tokens -> my_notify_contact_tokens
1083
+ ).
1084
+ }
1085
+
1086
+ fn import_notify_state (data: any) -> nil
1087
+ {
1088
+ // Every field is optional (an export that predates this LIBRARY —
1089
+ // i.e. carries no notify fields at all — imports unchanged with the
1090
+ // defaults in place; 0.3-era exports with token-bearing registration
1091
+ // records are NOT a supported input, no such deployments exist).
1092
+ // pending_notify_registers is transient by design: an in-flight
1093
+ // register/rotate does not survive an export/import (re-register is
1094
+ // idempotent — E8).
1095
+ if (data $my_notify_registrations) != NIL
1096
+ {
1097
+ my_notify_registrations -> (data $my_notify_registrations) safe (global_id ->> my_registration_t).
1098
+ }
1099
+ if (data $notify_registrations) != NIL
1100
+ {
1101
+ notify_registrations -> (data $notify_registrations) safe (global_id ->> registration_t).
1102
+ }
1103
+ if (data $notify_token_index) != NIL
1104
+ {
1105
+ notify_token_index -> (data $notify_token_index) safe (global_id ->> global_id).
1106
+ }
1107
+ if (data $vapid_public_key) != NIL
1108
+ {
1109
+ vapid_public_key -> (data $vapid_public_key) safe str.
1110
+ }
1111
+ // v2 fields: individually guarded so a v1-era export imports cleanly
1112
+ // with the defaults (empty maps) in place — fixture test relies on this.
1113
+ if (data $notify_sender_tokens) != NIL
1114
+ {
1115
+ notify_sender_tokens -> (data $notify_sender_tokens) safe (global_id ->> (global_id ->> notify_token_t)).
1116
+ }
1117
+ if (data $notify_sender_muted) != NIL
1118
+ {
1119
+ notify_sender_muted -> (data $notify_sender_muted) safe (global_id ->> (global_id ->> bool)).
1120
+ }
1121
+ if (data $my_notify_contact_tokens) != NIL
1122
+ {
1123
+ my_notify_contact_tokens -> (data $my_notify_contact_tokens) safe (global_id ->> (global_id ->> notify_token_t)).
1124
+ }
1125
+ pending_notify_registers -> (,).
1126
+ }
1127
+ }