@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.
- package/LICENSE +98 -0
- package/README.md +231 -0
- package/dist/cli.js +446 -0
- package/dist/config.js +104 -0
- package/dist/daemon.js +353 -0
- package/dist/http.js +31 -0
- package/dist/log-store.js +48 -0
- package/dist/presence.js +157 -0
- package/dist/provision.js +29 -0
- package/dist/push.js +6 -0
- package/dist/web-push-sender.js +41 -0
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +21 -0
- package/mufl_code/core/.github/FUNDING.yml +3 -0
- package/mufl_code/core/.github/workflows/docs.yml +39 -0
- package/mufl_code/core/.github/workflows/tests.yml +66 -0
- package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
- package/mufl_code/core/COMPATIBILITY.md +221 -0
- package/mufl_code/core/CONTRIBUTING.md +37 -0
- package/mufl_code/core/LICENSE +98 -0
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
- package/mufl_code/core/README.md +91 -0
- package/mufl_code/core/SECURITY.md +40 -0
- package/mufl_code/core/TRADEMARKS.md +24 -0
- package/mufl_code/core/a2a_capabilities.mm +449 -0
- package/mufl_code/core/a2a_cluster.mm +601 -0
- package/mufl_code/core/a2a_control.mm +90 -0
- package/mufl_code/core/a2a_messaging.mm +4817 -0
- package/mufl_code/core/a2a_monitoring.mm +94 -0
- package/mufl_code/core/a2a_notification_integration.mm +144 -0
- package/mufl_code/core/a2a_notifications.mm +1127 -0
- package/mufl_code/core/a2a_protocol.mm +246 -0
- package/mufl_code/core/a2a_versions.mm +801 -0
- package/mufl_code/core/config.mufl +29 -0
- package/mufl_code/core/docs/.vitepress/config.mts +58 -0
- package/mufl_code/core/docs/citations.json +688 -0
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
- package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
- package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
- package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
- package/mufl_code/core/docs/guide/index.md +63 -0
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
- package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
- package/mufl_code/core/docs/how-it-works/identity.md +56 -0
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
- package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
- package/mufl_code/core/docs/how-it-works/overview.md +45 -0
- package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
- package/mufl_code/core/docs/index.md +27 -0
- package/mufl_code/core/docs/package-lock.json +3467 -0
- package/mufl_code/core/docs/package.json +15 -0
- package/mufl_code/core/docs/reference/contributing.md +26 -0
- package/mufl_code/core/docs/reference/glossary.md +108 -0
- package/mufl_code/core/docs/reference/implementations.md +23 -0
- package/mufl_code/core/docs/reference/modules.md +19 -0
- package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
- package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
- package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
- package/mufl_code/core/docs/workflows/cluster.md +89 -0
- package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
- package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
- package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
- package/mufl_code/core/docs/workflows/index.md +57 -0
- package/mufl_code/core/docs/workflows/introductions.md +60 -0
- package/mufl_code/core/docs/workflows/messaging.md +75 -0
- package/mufl_code/core/docs/workflows/monitoring.md +84 -0
- package/mufl_code/core/docs/workflows/notifications.md +109 -0
- package/mufl_code/core/release-notes/0.1.md +60 -0
- package/mufl_code/core/release-notes/0.3.md +22 -0
- package/mufl_code/core/release-notes/0.4.md +97 -0
- package/mufl_code/core/tests/README.md +83 -0
- package/mufl_code/core/tests/compat/README.md +82 -0
- package/mufl_code/core/tests/compat/compat.mjs +245 -0
- package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
- package/mufl_code/core/tests/compat/run_compat.sh +96 -0
- package/mufl_code/core/tests/corpus.mjs +139 -0
- package/mufl_code/core/tests/corpus_actor.mu +242 -0
- package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
- package/mufl_code/core/tests/mig.mjs +805 -0
- package/mufl_code/core/tests/mig_actor.mu +518 -0
- package/mufl_code/core/tests/migapp.mjs +431 -0
- package/mufl_code/core/tests/migapp_actor.mu +294 -0
- package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
- package/mufl_code/core/tests/notif.mjs +755 -0
- package/mufl_code/core/tests/notif_actor.mu +450 -0
- package/mufl_code/core/tests/protocol_container.mm +18 -0
- package/mufl_code/core/tests/run.sh +65 -0
- package/mufl_code/core/tests/run_corpus.sh +51 -0
- package/mufl_code/core/tests/run_fatcompile.sh +40 -0
- package/mufl_code/core/tests/run_mig.sh +51 -0
- package/mufl_code/core/tests/run_migapp.sh +51 -0
- package/mufl_code/core/tests/run_notif.sh +57 -0
- package/mufl_code/core/tests/test.mjs +750 -0
- package/mufl_code/core/tests/test_actor.mu +564 -0
- package/mufl_code/core/tests/test_common.mjs +93 -0
- package/mufl_code/core/version.mm +24 -0
- package/mufl_code/packet.mu +170 -0
- package/mufl_code/protocol_container.mm +18 -0
- package/package.json +43 -0
- 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
|
+
}
|