social-kit / src / ardegazu / social / presence.cljs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
;; ported-from: src/presence.ts @ v1.2.0
;;
;; The social agent: presence beacons, live envelope delivery and offline
;; catch-up, one instance per app tab.
;;
;; Transport is gossipsub TOPICS only (works on both libp2p generations the
;; suite runs — the games' v3 and chat/board's v2 — via the tiny PubsubLike
;; surface), plus the mailbox for offline. Per friendship the agent joins the
;; pair channel (pair.cljs): sealed beacons at a relaxed cadence while the tab
;; is visible, sealed envelopes for invites and crossed friend flows; the
;; self channel keeps my own devices' friend lists folded.
;;
;; Privacy: topics are HKDF-opaque, senders are keyed tags, everything is
;; sealed per channel — outsiders can't link a topic to anyone. A `join` link
;; in a beacon is a capability grant and is only ever included when the app
;; explicitly opts in. Honest limit: presence exists only while a tab is
;; open — there is no server to be online for you.
;;
;; Shape of this namespace: `_o` is the caller's live options object and is
;; read through `opt` at every use, never snapshotted — an app may swap its
;; pubsub or hand a fresh `myName` thunk between rounds. Channel state, beacon
;; payloads and presence entries are JS objects built with `obj`/`obj-when`,
;; because this dist has no ClojureScript collection runtime in it at all (see
;; test/module-budget.test.mjs for what the first one would cost `./join`).
(ns ardegazu.social.presence
  (:require [ardegazu.id.crypto :as id-crypto]
            [ardegazu.id.xkey :as id-xkey]
            [ardegazu.social.canon :as canon]
            [ardegazu.social.consts :as consts]
            [ardegazu.social.envelope :as envelope]
            [ardegazu.social.friends :as friends]
            [ardegazu.social.mailbox :as mailbox]
            [ardegazu.social.pair :as pair]
            [ardegazu.social.selfsync :as selfsync]
            [shadow.cljs.modern :refer (defclass)])
  (:require-macros [ardegazu.social.macros :refer [awaits obj obj-when oget truthy?]]))

(def ^:private td (js/TextDecoder.))
(def ^:private INBOX-POLL-MS 120000)
(def ^:private DROP-POLL-MS 300000)
(def ^:private STALE-SWEEP-MS 15000)
(def ^:private REPLAY-LIMIT 50)
(def ^:private REPLAY-PAGES 5)
(def ^:private SYNC-DEBOUNCE-MS 1500)
;; An unchanged self-sync still redeposits past this age — the deposit is
;; what refreshes the self drop's 7-day mailbox TTL.
(def ^:private SYNC-FRESH-MS (* 20 3600000))

(def ^:private NAME-MAX 32)
(def ^:private APP-MAX 64)
(def ^:private LABEL-MAX 80)
(def ^:private MSG-CODE-MAX 24)

(def ^:private join-link-re #"^https://[a-z0-9-]+\.ardegazu\.ro/")

;; ---- brains (the optional "what I'm playing with" beacon field) -------------
;;
;; A peer may advertise a small map of game id -> brain/model version it is
;; currently playing (`br` on the beacon wire, `brains` on the presence entry).
;; The bots fill it; browser clients leave the option unset and their beacon
;; bytes are unchanged. Additive and signature-safe: beacons are sealed
;; payloads (pair.cljs seals JSON.stringify(payload)) with no signature at all,
;; and envelope signatures are over canon() of whatever the envelope carries —
;; so an old client simply never reads the key.
;;
;; SANITIZED IDENTICALLY ON SEND AND RECEIVE (sanitize-brains): received values
;; are untrusted network input that consuming UIs render. Consumers MUST render
;; these strings via textContent — NEVER innerHTML.
(def ^:private BRAINS-MAX 8)
(def ^:private brain-key-re #"^[a-z0-9][a-z0-9-]{0,23}$")
(def ^:private brain-val-re #"^[A-Za-z0-9@._-]{1,32}$")

(defn- plain-object?
  "A non-null JS object that is not an array. Arrays are rejected outright
  wherever this is used as a map guard — their numeric keys would otherwise
  pass a key charset check."
  [v]
  (and (some? v)
       (identical? (js* "typeof ~{}" v) "object")
       (not (js/Array.isArray v))))

(defn- sanitize-brains
  "Clamp one untrusted brains map into a fresh object, or nil.

  At most BRAINS-MAX entries, first-valid wins in key order; keys must match
  brain-key-re; values must be STRINGS matching brain-val-re (non-strings are
  dropped, never stringified — which also bounds the length at 32); nil when
  nothing survives."
  [b]
  (if-not (plain-object? b)
    nil
    (let [ks (js/Object.keys b)
          n (.-length ks)
          out (js-obj)]
      (loop [i 0 kept 0]
        (if (or (>= i n) (>= kept BRAINS-MAX))
          (if (zero? kept) nil out)
          (let [k (aget ks i)
                v (oget b k)]
            (if (and (.test brain-key-re k) (string? v) (.test brain-val-re v))
              (do (unchecked-set out k v)
                  (recur (inc i) (inc kept)))
              (recur (inc i) kept))))))))

(defn- clamped
  "One untrusted string field, or \"\" when it is not a string at all."
  [v max-len]
  (if (string? v) (.slice ^js v 0 max-len) ""))

(defn- base64->bytes [b64]
  (let [bin (js/atob b64)
        n (.-length bin)
        out (js/Uint8Array. n)]
    (loop [i 0]
      (if (>= i n)
        out
        (do (aset out i (.charCodeAt bin i))
            (recur (inc i)))))))

;; ---- the agent ---------------------------------------------------------------

(defclass SocialAgent
  (constructor [this opts]
    (unchecked-set this "_o" opts)
    (unchecked-set this "_x" nil)
    (unchecked-set this "_selfCrypto" nil)
    (unchecked-set this "_channels" (js/Map.)) ; topic -> state
    (unchecked-set this "_byFriend" (js/Map.)) ; friendPub -> state
    (unchecked-set this "_presence" (js/Map.))
    (unchecked-set this "_seen" (envelope/SeenRing. (str (unchecked-get opts "ns") ":soc:seen-env")
                                                    js/undefined))
    (unchecked-set this "_inbox" nil)
    (unchecked-set this "_timers" #js [])
    (unchecked-set this "_syncTimer" nil)
    (unchecked-set this "_stopped" false)
    (unchecked-set this "_visListener" nil)))

;; string-named getter (rename-safe under :advanced)
(js/Object.defineProperty
 (.-prototype SocialAgent) "myPub"
 #js {:get (fn []
             (this-as self
               (unchecked-get (unchecked-get (unchecked-get self "_o") "identity") "publicKeyB64")))
      :configurable true})

(defn- opt
  "One option off the caller's LIVE options object. Never snapshotted: an app
  may swap its pubsub or replace a thunk between rounds, and the deployed
  contract is that the next round sees it."
  [self k]
  (oget self "_o" k))

(defn- my-pub [self]
  (oget self "_o" "identity" "publicKeyB64"))

(defn- visible? []
  (identical? (.-visibilityState js/document) "visible"))

(defn- fire-presence [self pub info]
  (when-let [cb (oget self "_o" "events" "presence")]
    (cb pub info)))

(defn- channel-state
  "What the agent tracks per joined channel. `friendPub` is nil for the self
  channel, which is how the two are told apart everywhere below."
  [ch friend-pub mbx]
  (obj "ch" ch "friendPub" friend-pub "mbx" mbx))

(defn- open-drop
  "The mailbox drop for a channel, or nil. Never fatal: a channel with no drop
  is live-only, which is exactly what an offline tab has anyway."
  [room-id]
  (-> (mailbox/mailbox-for room-id) (.catch (fn [_] nil))))

;; ---- channels ------------------------------------------------------------

(defn- register-channel! [self ch friend-pub mbx]
  (let [st (channel-state ch friend-pub mbx)]
    (.set ^js (oget self "_channels") (oget ch "topic") st)
    (when (some? friend-pub)
      (.set ^js (oget self "_byFriend") friend-pub st))
    (when-let [pubsub (opt self "pubsub")]
      (.subscribe ^js pubsub (oget ch "topic")))
    nil))

(defn- join-self [self]
  (awaits [ch (pair/self-channel (opt self "seed"))
           mbx (open-drop (oget ch "mailboxRoomId"))]
    (register-channel! self ch nil mbx)))

(defn- join-friend-channel
  "Join (or re-join) the channel for one friend record."
  [self f]
  (if (.has ^js (oget self "_byFriend") (oget f "pub"))
    (js/Promise.resolve nil)
    (-> (awaits [_ (js/Promise.resolve nil)
                 ch (pair/pair-channel (oget self "_x") (my-pub self)
                                       (oget f "pub") (oget f "x"))
                 mbx (open-drop (oget ch "mailboxRoomId"))]
          (register-channel! self ch (oget f "pub") mbx))
        ;; malformed key — record stays, channel just doesn't exist
        (.catch (fn [_] nil)))))

(defn- join-friend-channels [self friends-arr i]
  (if (>= i (.-length ^js friends-arr))
    (js/Promise.resolve nil)
    (awaits [_ (join-friend-channel self (aget friends-arr i))]
      (join-friend-channels self friends-arr (inc i)))))

(defn- leave-friend-channel [self pub]
  (let [st (.get ^js (oget self "_byFriend") pub)]
    (when-not (identical? st js/undefined)
      (let [topic (oget st "ch" "topic")]
        (.delete ^js (oget self "_byFriend") pub)
        (.delete ^js (oget self "_channels") topic)
        (when-let [pubsub (opt self "pubsub")]
          (.unsubscribe ^js pubsub topic))
        (.delete ^js (oget self "_presence") pub)
        (fire-presence self pub nil)))
    js/undefined))

;; ---- beacons ---------------------------------------------------------------

(defn- beacon-loop
  "Publish one payload to every friend channel, in turn. A friend we have a
  channel for but have not accepted never gets beaconed at."
  [self states i payload]
  (if (>= i (.-length ^js states))
    (js/Promise.resolve nil)
    (let [st (aget states i)
          f (.get ^js (opt self "store") (oget st "friendPub"))]
      (if (or (nil? f) (not (identical? (oget f "state") "friend")))
        (beacon-loop self states (inc i) payload)
        (-> (awaits [_ (js/Promise.resolve nil)
                     wire ((oget st "ch" "seal") (my-pub self) payload)]
              (.publish ^js (opt self "pubsub") (oget st "ch" "topic") (id-crypto/utf8 wire)))
            (.catch (fn [_] nil)) ; not subscribed yet / no peers — fine
            (.then (fn [_] (beacon-loop self states (inc i) payload))))))))

(defn- friend-states [self]
  (js/Array.from (.values ^js (oget self "_byFriend"))))

(defn- beacon-brains
  "The app's optional brains thunk, clamped. App/bot code: a throwing thunk
  omits the field, never breaks the beacon — and it is clamped on SEND too, so
  a misbehaving caller cannot bloat every beacon to every friend.

  `joinLink` next to it is deliberately NOT wrapped: that is how it shipped,
  and a throwing joinLink still takes the whole beacon down. Changing it is a
  behaviour change, not a rewrite, so it is left alone and written down."
  [self]
  (try
    (when-let [bf (opt self "brains")]
      (sanitize-brains (bf)))
    (catch :default _ nil))) ; omitted

(defn- beacon-payload
  "The live beacon. Key order is the wire shape: k, name, app, [join], [br],
  ts — `join` is a capability grant and only present when the app switched
  sharing on, `br` only when the peer has brains to advertise."
  [self]
  (let [join (when-let [jl (opt self "joinLink")] (jl))
        brains (beacon-brains self)]
    (obj-when "k" "b"
              "name" ((opt self "myName"))
              "app" (opt self "app")
              "join" (if (truthy? join) join nil)
              "br" brains
              "ts" (js/Date.now))))

(defn- beacon-all [self]
  (if (or (nil? (opt self "pubsub")) (not (visible?)))
    (js/Promise.resolve nil)
    (beacon-loop self (friend-states self) 0 (beacon-payload self))))

(defn- bye-all [self]
  (if (nil? (opt self "pubsub"))
    (js/Promise.resolve nil)
    (beacon-loop self (friend-states self) 0 (obj "k" "bye"))))

(defn- sweep
  "Drop presence entries nobody has refreshed inside the stale window. There is
  no server to tell us a peer left; silence is the only signal."
  [self]
  (let [cut (- (js/Date.now) consts/SOC-STALE-MS)
        entries (js/Array.from (.entries ^js (oget self "_presence")))
        n (.-length entries)]
    (loop [i 0]
      (when (< i n)
        (let [e (aget entries i)
              pub (aget e 0)]
          (when (< (oget (aget e 1) "ts") cut)
            (.delete ^js (oget self "_presence") pub)
            (fire-presence self pub nil))
          (recur (inc i)))))))

;; ---- send paths -------------------------------------------------------------

(defn- live-envelope
  "Push an already-sealed envelope down the live pair topic. False whenever
  that could not happen, which is the caller's cue to fall back to the drop."
  [self pub outer]
  (let [st (.get ^js (oget self "_byFriend") pub)]
    (if (or (identical? st js/undefined) (nil? (opt self "pubsub")))
      (js/Promise.resolve false)
      (-> (awaits [_ (js/Promise.resolve nil)
                   wire ((oget st "ch" "seal") (my-pub self) (obj "k" "env" "env" outer))
                   _ (.publish ^js (opt self "pubsub") (oget st "ch" "topic") (id-crypto/utf8 wire))]
            true)
          (.catch (fn [_] false))))))

(defn- self-crypto-now
  "The signing bundle, with the name re-read now — a profile edit has to show
  up on the very next envelope."
  [self]
  (let [sc (js/Object.assign (js-obj) (oget self "_selfCrypto"))]
    (unchecked-set sc "name" ((opt self "myName")))
    sc))

;; ---- self-sync ---------------------------------------------------------------

(defn- self-channel-state [self]
  (.find (js/Array.from (.values ^js (oget self "_channels")))
         (fn [s] (nil? (oget s "friendPub")))))

(defn- app-section
  "The app's own opaque state section, namespaced so a different app's blob is
  ignored on arrival. Omitted rather than fatal when the hook throws or the
  state is not JSON-safe — the social layer must never break an app."
  [self]
  (try
    (let [as (opt self "appState")
          state (when (some? as) ((oget as "get")))]
      (when (some? state)
        (canon/canon state) ; throws on non-JSON-safe state — omit, don't die
        (obj "ns" (opt self "ns") "state" state)))
    (catch :default _ nil)))

(defn- apps-section
  "The suite identity record's per-app map (id-kit's `apps` field), sanitized
  before it goes out. NOT `appState` above: that one is a single blob for THIS
  app, gated on `ns` at the far end; this one is the whole map, every app's
  key, folded per key on arrival. Same degrade-don't-die rule — a throwing
  get() or an unusable map omits the section rather than failing the sync."
  [self]
  (try
    (when-let [as (opt self "appsSync")]
      (selfsync/sanitize-apps-sync ((oget as "get"))))
    (catch :default _ nil)))

(defn- sync-body
  "What one fsync carries: the friend blob, plus the optional sections old kits
  read straight past."
  [self]
  (let [soc (.socBlob ^js (opt self "store"))
        ;; the hooks are app code: a throwing get() or non-JSON-safe state
        ;; degrades to omitting that section, never to failing the whole sync
        profile (try
                  (when-let [ps (opt self "profileSync")]
                    (selfsync/sanitize-profile-sync ((oget ps "get"))))
                  (catch :default _ nil))] ; omitted
    (obj-when "soc" soc
              "profile" profile
              "app" (app-section self)
              "apps" (apps-section self))))

(defn- sync-fingerprint
  "Hash the payload with socBlob's per-call timestamp zeroed, so an unchanged
  friend list hashes the same on every round."
  [body]
  (let [soc0 (js/Object.assign (js-obj) (oget body "soc"))
        hash-body (js/Object.assign (js-obj) body)]
    (unchecked-set soc0 "ts" 0)
    (unchecked-set hash-body "soc" soc0)
    (canon/sha256-b64url (canon/canon hash-body))))

(defn- sync-fresh?
  "Have we already sent exactly this, recently enough? The deposit is what
  refreshes the self drop's 7-day TTL, so an unchanged payload still goes out
  once past SYNC-FRESH-MS."
  [hash-key h]
  (try
    (let [prev (js/JSON.parse (or (js/localStorage.getItem hash-key) "null"))]
      (and (some? prev)
           (identical? (oget prev "h") h)
           (number? (oget prev "ts"))
           (< (- (js/Date.now) (oget prev "ts")) SYNC-FRESH-MS)))
    (catch :default _ false))) ; no record — send

(defn- remember-sync! [hash-key h]
  (try
    (js/localStorage.setItem hash-key (js/JSON.stringify (obj "h" h "ts" (js/Date.now))))
    (catch :default _ nil))) ; private mode: gate is session-only

(defn- push-live
  "Offer the envelope to whatever other device is listening right now. Never
  required to succeed — the drop below is the delivery that matters."
  [self self-st built]
  (if-not (some? (opt self "pubsub"))
    (js/Promise.resolve nil)
    (-> (awaits [wire ((oget self-st "ch" "seal")
                       (my-pub self)
                       (obj "k" "env" "env" (oget built "outer")))]
          (.publish ^js (opt self "pubsub") (oget self-st "ch" "topic") (id-crypto/utf8 wire)))
        (.catch (fn [_] nil))))) ; no other device online

(defn- sync-self
  "Push my friend list (+ optional profile/app sections) to my other
  devices: live + self drop. Old kits read only body.soc and ignore the
  extra sections — same envelope type, purely additive. Unless `force`,
  an unchanged payload with a fresh-enough deposit skips the wire."
  [self force]
  (let [self-st (self-channel-state self)]
    (if (identical? self-st js/undefined)
      (js/Promise.resolve nil)
      (let [body (sync-body self)
            hash-key (str (opt self "ns") ":soc:last-self-sync")]
        (awaits [h (sync-fingerprint body)]
          (if (and (not force) (sync-fresh? hash-key h))
            nil
            (awaits [built (envelope/build-envelope
                            (self-crypto-now self)
                            (pair/env-ctx (my-pub self))
                            (my-pub self)
                            (oget self "_x" "pubB64")
                            "fsync"
                            body
                            js/undefined)
                     _ (push-live self self-st built)]
              ;; deposit and the hash write land in the same turn, as they
              ;; shipped: the deposit is fire-and-forget and refreshes the
              ;; drop's 7-day TTL, the hash is what suppresses the next round
              (when-let [mbx (oget self-st "mbx")]
                (.deposit ^js mbx (oget built "bytes")))
              (remember-sync! hash-key h)
              nil)))))))

;; ---- inbound wire ------------------------------------------------------------

(defn- deliver-invite!
  "An invite from a friend. Only friends may invite, and only to a suite host —
  the URL in an invite is a capability, so it is checked before it is ever
  handed to a UI."
  [self store from-pub env body]
  (let [f (.get ^js store from-pub)
        url (oget body "url")]
    (when (and (some? f)
               (identical? (oget f "state") "friend")
               (string? url)
               (.test join-link-re url))
      (let [m (oget body "m")
            mk (when (some? m) (oget m "k"))]
        (when-let [cb (oget self "_o" "events" "invite")]
          (cb (obj "id" (oget env "id")
                   "from" f
                   "app" (clamped (oget body "app") APP-MAX)
                   "url" url
                   "label" (clamped (oget body "label") LABEL-MAX)
                   "msg" (if (string? mk) (obj "k" (.slice ^js mk 0 MSG-CODE-MAX)) js/undefined)
                   "ts" (oget env "ts"))))))
    nil))

(defn- apply-self-sections!
  "The optional profile/app/apps sections from one of my own devices. Each is
  applied inside its OWN try: an app hook that misbehaves must not stop the
  next section, and must never stop the friend-list fold that follows."
  [self body]
  (try
    (when (truthy? (oget body "profile"))
      (when-let [p (selfsync/sanitize-profile-sync (oget body "profile"))]
        (when-let [ps (opt self "profileSync")]
          ((oget ps "apply") p))))
    (catch :default _ nil))
  (try
    (let [app-sec (oget body "app")]
      (when (and (truthy? app-sec)
                 (identical? (oget app-sec "ns") (opt self "ns"))
                 (some? (opt self "appState")))
        ((oget self "_o" "appState" "apply") (oget app-sec "state"))))
    (catch :default _ nil))
  (try
    (when (truthy? (oget body "apps"))
      (when-let [m (selfsync/sanitize-apps-sync (oget body "apps"))]
        (when-let [as (opt self "appsSync")]
          ((oget as "apply") m))))
    (catch :default _ nil))
  nil)

(defn- fold-social!
  "Hand the envelope to the friend rules, then react to what changed: a record
  that did not exist before, or one that has just become a friendship, needs a
  channel — and either way my other devices should hear about it."
  [self store from-pub env]
  (let [before (let [f (.get ^js store from-pub)]
                 (if (some? f) (oget f "state") js/undefined))
        events (opt self "events")
        changed (friends/apply-friend-envelope
                 store env
                 (obj "request" (fn [f] (when-let [cb (oget events "request")] (cb f)))
                      "accepted" (fn [f] (when-let [cb (oget events "accepted")] (cb f)))
                      "synced" (fn [n] (when-let [cb (oget events "synced")] (cb n))))
                 (my-pub self))]
    (when changed
      (let [f (.get ^js store from-pub)]
        (when (and (some? f) (identical? before js/undefined))
          (join-friend-channel self f))
        (when (and (some? f)
                   (identical? (oget f "state") "friend")
                   (not (.has ^js (oget self "_byFriend") (oget f "pub"))))
          (join-friend-channel self f))
        (sync-self self false)))
    nil))

(defn- route-envelope
  "One opened envelope, from whichever transport carried it. Dedup by envelope
  id is what makes the three transports interchangeable."
  [self bytes]
  (awaits [env (envelope/open-envelope (oget self "_x")
                                       (pair/env-ctx (my-pub self)) bytes)]
    (if (or (nil? env) ^boolean (.has ^js (oget self "_seen") (oget env "id")))
      nil
      (do
        (.add ^js (oget self "_seen") (oget env "id"))
        (let [from-pub (oget env "from" "pub")
              store (opt self "store")
              t (oget env "t")
              body (oget env "body")]
          (cond
            (true? (.isBlocked ^js store from-pub))
            nil

            (identical? t "inv")
            (deliver-invite! self store from-pub env body)

            ;; self-sync must come from self — inbox room ids are publicly
            ;; derivable, so anyone can seal a valid fsync (signed with their
            ;; own key) into ours
            (and (identical? t "fsync") (not (identical? from-pub (my-pub self))))
            nil

            :else
            (do
              (when (identical? t "fsync")
                (apply-self-sections! self body))
              (fold-social! self store from-pub env))))))))

(defn- presence-entry
  "One peer's live state, from an untrusted beacon. Same clamps the send side
  applies; `join` is present-but-null when the peer is not sharing, `brains`
  absent entirely when there is nothing to say (legacy peers never set it)."
  [p]
  (let [join (oget p "join")
        brains (sanitize-brains (oget p "br"))
        info (obj "name" (clamped (oget p "name") NAME-MAX)
                  "app" (clamped (oget p "app") APP-MAX)
                  "join" (if (and (string? join) (.test join-link-re join)) join nil)
                  "ts" (js/Date.now))]
    (when (some? brains)
      (unchecked-set info "brains" brains))
    info))

(defn- on-wire
  "A sealed frame off one channel's topic. Members are the only identities it
  can have come from, so a frame no member's tag matches is simply not ours."
  [self st data]
  (let [friend-pub (oget st "friendPub")
        members (if (some? friend-pub) #js [friend-pub] #js [(my-pub self)])]
    (awaits [opened ((oget st "ch" "open") (.decode td data) members)]
      (let [p (when (some? opened) (oget opened "payload"))]
        (if-not (plain-object? p)
          nil
          (let [k (oget p "k")]
            (cond
              (and (identical? k "b") (some? friend-pub))
              (let [info (presence-entry p)]
                (.set ^js (oget self "_presence") friend-pub info)
                (.seen ^js (opt self "store") friend-pub (oget info "app"))
                (fire-presence self friend-pub info)
                nil)

              (and (identical? k "bye") (some? friend-pub))
              (do (.delete ^js (oget self "_presence") friend-pub)
                  (fire-presence self friend-pub nil)
                  nil)

              (and (identical? k "env") (truthy? (oget p "env")))
              (route-envelope self (id-crypto/utf8 (js/JSON.stringify (oget p "env"))))

              :else nil)))))))

;; ---- offline catch-up -------------------------------------------------------

(defn- drain-messages
  "Replay one page, in order. The cursor advances past a message that could not
  be routed — an undecodable deposit is someone else's or garbage, and
  retrying it forever would wedge the replay."
  [self msgs i state]
  (if (>= i (.-length ^js msgs))
    (js/Promise.resolve nil)
    (let [m (aget msgs i)]
      (-> (awaits [_ (js/Promise.resolve nil)]
            (route-envelope self (base64->bytes (oget m "payload"))))
          (.catch (fn [_] nil))
          (.then (fn [_]
                   (unchecked-set state "cursor" (oget m "seq"))
                   (drain-messages self msgs (inc i) state)))))))

(defn- drain-pages [self mbx cursor-key state page]
  (if (>= page REPLAY-PAGES)
    (js/Promise.resolve nil)
    (awaits [r (.replay ^js mbx (oget state "cursor") REPLAY-LIMIT)]
      (if-not ^boolean (oget r "ok")
        nil
        (let [pg (oget r "page")]
          (awaits [_ (drain-messages self (oget pg "messages") 0 state)]
            ;; persist the cursor per page, not per drain: a tab closed
            ;; mid-replay must not re-deliver what it already showed
            (when (truthy? (oget state "cursor"))
              (try
                (js/localStorage.setItem cursor-key (oget state "cursor"))
                (catch :default _ nil))) ; ignore
            (if ^boolean (oget pg "hasMore")
              (drain-pages self mbx cursor-key state (inc page))
              nil)))))))

(defn- drain [self mbx cursor-key]
  (let [cursor (try (js/localStorage.getItem cursor-key)
                    (catch :default _ nil))] ; session-only
    (drain-pages self mbx cursor-key (obj "cursor" cursor) 0)))

(defn- poll-inbox [self force]
  (if (or (nil? (oget self "_inbox"))
          (and (not force) (not (visible?))))
    (js/Promise.resolve nil)
    (drain self (oget self "_inbox") (str (opt self "ns") ":soc:inbox-cursor"))))

(defn- poll-drop-states [self states i]
  (if (>= i (.-length ^js states))
    (js/Promise.resolve nil)
    (let [st (aget states i)]
      (if-not (some? (oget st "mbx"))
        (poll-drop-states self states (inc i))
        (awaits [_ (drain self (oget st "mbx")
                          (str (opt self "ns") ":soc:mbx:"
                               (.slice ^js (oget st "ch" "id") 0 16)))]
          (poll-drop-states self states (inc i)))))))

(defn- poll-drops [self force]
  (if (and (not force) (not (visible?)))
    (js/Promise.resolve nil)
    (poll-drop-states self (js/Array.from (.values ^js (oget self "_channels"))) 0)))

;; ---- lifecycle --------------------------------------------------------------

(defn- on-interval [self ms f]
  (.push ^js (oget self "_timers")
         (js/window.setInterval (fn []
                                  (when-not ^boolean (oget self "_stopped")
                                    (f)))
                                ms)))

(defn- start [self]
  (awaits [x (id-xkey/derive-suite-x-key-pair (opt self "seed"))]
    (unchecked-set self "_x" x)
    (awaits [x-cert (id-xkey/sign-x-cert (opt self "identity") id-xkey/SUITE-SALT (oget x "pubB64"))]
      (unchecked-set self "_selfCrypto"
                     (obj "identity" (opt self "identity")
                          "x" x
                          "xCert" x-cert
                          "name" ((opt self "myName"))))
      (awaits [inbox-room (pair/inbox-room-id (my-pub self))
               inbox (open-drop inbox-room)]
        (unchecked-set self "_inbox" inbox)
        ;; channels: self + one per known friend record (any state — the accept
        ;; and crossed-request flows should arrive live too)
        (awaits [_ (join-self self)
                 _ (join-friend-channels self (.list ^js (opt self "store")) 0)]
          (when-let [pubsub (opt self "pubsub")]
            (.addEventListener ^js pubsub "message"
                               (fn [evt]
                                 (let [detail (oget evt "detail")
                                       st (.get ^js (oget self "_channels") (oget detail "topic"))]
                                   (when-not (identical? st js/undefined)
                                     (on-wire self st (oget detail "data")))))))

          ;; beacons + polls, all visibility-gated
          (on-interval self consts/SOC-BEACON-MS (fn [] (beacon-all self)))
          (on-interval self STALE-SWEEP-MS (fn [] (sweep self)))
          (on-interval self INBOX-POLL-MS (fn [] (poll-inbox self false)))
          (on-interval self DROP-POLL-MS (fn [] (poll-drops self false)))
          ;; NOTE the order here is not boot's order below, and never was:
          ;; becoming visible again beacons FIRST (friends should see us back
          ;; immediately), then catches up; a cold boot polls first.
          (let [vis (fn []
                      (if (visible?)
                        (do (beacon-all self)
                            (poll-inbox self false)
                            (poll-drops self false))
                        (bye-all self)))]
            (unchecked-set self "_visListener" vis)
            (js/document.addEventListener "visibilitychange" vis))
          (js/window.addEventListener "pagehide" (fn [] (bye-all self)))

          ;; the boot catch-up always runs — a tab opened in the background must
          ;; still converge; only the PERIODIC polls are visibility-gated
          (poll-inbox self true)
          (poll-drops self true)
          (beacon-all self)
          (sync-self self true) ; boot deposit is unconditional — it re-arms the TTL
          js/undefined)))))

(defn- stop [self]
  (unchecked-set self "_stopped" true)
  (let [timers ^js (oget self "_timers")
        n (.-length timers)]
    (loop [i 0]
      (when (< i n)
        (js/clearInterval (aget timers i))
        (recur (inc i)))))
  (when (some? (oget self "_syncTimer"))
    (js/clearTimeout (oget self "_syncTimer")))
  (when-let [vis (oget self "_visListener")]
    (js/document.removeEventListener "visibilitychange" vis))
  (when-let [pubsub (opt self "pubsub")]
    (let [topics (js/Array.from (.keys ^js (oget self "_channels")))
          n (.-length topics)]
      (loop [i 0]
        (when (< i n)
          (.unsubscribe ^js pubsub (aget topics i))
          (recur (inc i))))))
  js/undefined)

;; ---- outbound actions --------------------------------------------------------

(defn- send-friend-request
  "Send a friend request to a parsed link target. The inbox deposit is the
  real delivery; the live push only helps when they happen to be around."
  [self to note]
  (let [note (if (identical? note js/undefined) "" note)
        to-pub (oget to "pub")]
    (awaits [built (envelope/build-envelope
                    (self-crypto-now self)
                    (pair/env-ctx to-pub)
                    to-pub
                    (oget to "x")
                    "freq"
                    (if (truthy? note)
                      (obj "msg" (.slice ^js note 0 200))
                      (js-obj))
                    js/undefined)
             inbox-room (pair/inbox-room-id to-pub)
             inbox (open-drop inbox-room)
             dep (if (some? inbox)
                   (.deposit ^js inbox (oget built "bytes"))
                   (js/Promise.resolve (obj "ok" false)))]
      (.upsert ^js (opt self "store")
               (obj "pub" to-pub
                    "x" (oget to "x")
                    "xs" (oget to "xs")
                    "pet" ""
                    "name" (oget to "name")
                    "state" "out"
                    "addedTs" (js/Date.now)
                    "petTs" 0
                    "lastSeenTs" 0
                    "lastApp" ""))
      (let [f (.get ^js (opt self "store") to-pub)]
        (awaits [_ (if (some? f)
                     ;; crossed-online case: they may already have asked us
                     (awaits [_ (join-friend-channel self f)]
                       (live-envelope self to-pub (oget built "outer")))
                     (js/Promise.resolve nil))]
          (sync-self self false)
          (oget dep "ok"))))))

(defn- accept-request
  "Accept a pending request."
  [self pub]
  (let [f (.get ^js (opt self "store") pub)]
    (if (or (nil? f) (not (identical? (oget f "state") "in")))
      (js/Promise.resolve false)
      (awaits [built (envelope/build-envelope
                      (self-crypto-now self)
                      (pair/env-ctx pub)
                      pub
                      (oget f "x")
                      "facc"
                      (js-obj)
                      js/undefined)
               inbox-room (pair/inbox-room-id pub)
               inbox (open-drop inbox-room)
               dep (if (some? inbox)
                     (.deposit ^js inbox (oget built "bytes"))
                     (js/Promise.resolve (obj "ok" false)))]
        (.markFriend ^js (opt self "store") pub)
        (awaits [_ (join-friend-channel self (.get ^js (opt self "store") pub))
                 _ (live-envelope self pub (oget built "outer"))]
          (sync-self self false)
          (beacon-all self)
          (oget dep "ok"))))))

(defn- invite-body [app url label msg]
  (if (some? msg)
    (obj "app" app "url" url "label" label
         "m" (obj "k" (oget msg "k") "v" 1))
    (obj "app" app "url" url "label" label)))

(defn- send-invite-loop
  "One friend at a time: live when they are reachable, parked in their pair
  drop otherwise. `sent` counts either kind of delivery."
  [self pubs i app url label msg sent]
  (if (>= i (.-length ^js pubs))
    (js/Promise.resolve sent)
    (let [pub (aget pubs i)
          f (.get ^js (opt self "store") pub)]
      (if (or (nil? f) (not (identical? (oget f "state") "friend")))
        (send-invite-loop self pubs (inc i) app url label msg sent)
        (awaits [built (envelope/build-envelope
                        (self-crypto-now self)
                        (pair/env-ctx pub)
                        pub
                        (oget f "x")
                        "inv"
                        (invite-body app url label msg)
                        consts/INVITE-TTL-MS)]
          (let [st (.get ^js (oget self "_byFriend") pub)]
            (awaits [live (live-envelope self pub (oget built "outer"))
                     dropped (if (and (not (identical? st js/undefined))
                                      (some? (oget st "mbx")))
                               ;; ^js on the binding SYMBOL, not on the (oget …)
                               ;; form — a tag on a macro call is dropped with
                               ;; the form it was read onto (see macros/oget)
                               (let [^js mbx (oget st "mbx")]
                                 (awaits [d (.deposit mbx (oget built "bytes"))]
                                   (oget d "ok")))
                               (js/Promise.resolve false))]
              (send-invite-loop self pubs (inc i) app url label msg
                                (if (or live dropped) (inc sent) sent)))))))))

;; ---- the JS façade -----------------------------------------------------------

(let [proto (.-prototype SocialAgent)]
  (unchecked-set proto "presenceOf"
                 (fn [pub]
                   (this-as self
                     (let [info (.get ^js (oget self "_presence") pub)]
                       (if (identical? info js/undefined) nil info)))))
  (unchecked-set proto "start"
                 (fn [] (this-as self (start self))))
  (unchecked-set proto "stop"
                 (fn [] (this-as self (stop self))))
  (unchecked-set proto "joinFriendChannel"
                 (fn [f] (this-as self (join-friend-channel self f))))
  (unchecked-set proto "leaveFriendChannel"
                 (fn [pub] (this-as self (leave-friend-channel self pub))))
  (unchecked-set proto "sendFriendRequest"
                 (fn [to note] (this-as self (send-friend-request self to note))))
  (unchecked-set proto "acceptRequest"
                 (fn [pub] (this-as self (accept-request self pub))))
  ;; Invite friends into a room: live when reachable, pair drop otherwise.
  ;; `msg` is a language-neutral code new receivers render in their own
  ;; language; `label` stays on the wire as the legacy prose old kits show.
  (unchecked-set proto "sendInvite"
                 (fn [pubs app url label msg]
                   (this-as self
                     (send-invite-loop self pubs 0 app url label
                                       (if (identical? msg js/undefined) nil msg) 0))))
  ;; Debounced self-sync for app-driven changes (profile edits, app state):
  ;; trailing ~1.5 s, then the hash gate in sync-self decides whether the
  ;; wire sees anything.
  (unchecked-set proto "syncNow"
                 (fn []
                   (this-as self
                     (when (some? (oget self "_syncTimer"))
                       (js/clearTimeout (oget self "_syncTimer")))
                     (unchecked-set self "_syncTimer"
                                    (js/window.setTimeout
                                     (fn []
                                       (unchecked-set self "_syncTimer" nil)
                                       (when-not ^boolean (oget self "_stopped")
                                         (sync-self self false)))
                                     SYNC-DEBOUNCE-MS))
                     js/undefined))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/social-kit.git