@ghostlytools/cli 1.3.10 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +13 -4
  2. package/SKILL.md +7 -4
  3. package/dist/assets/{api-Cp9GxjUO.js → api-BqLfti32.js} +2 -2
  4. package/dist/assets/{api-BCrwJ57L.js → api-JMkEO0RR.js} +130 -50
  5. package/dist/assets/api-JMkEO0RR.js.map +1 -0
  6. package/dist/assets/{apiKit-B4qhnLQT.js → apiKit-BpIUCwa-.js} +3 -3
  7. package/dist/assets/{apiKit-B4qhnLQT.js.map → apiKit-BpIUCwa-.js.map} +1 -1
  8. package/dist/assets/{apps-CpxENMgl.js → apps-BdfyGVZ1.js} +1 -1
  9. package/dist/assets/{apps-DOH1P_S0.js → apps-Bettj7WQ.js} +6 -6
  10. package/dist/assets/apps-Bettj7WQ.js.map +1 -0
  11. package/dist/assets/{arkade-Cyf5MGxG.js → arkade-CP4HvKaa.js} +2 -2
  12. package/dist/assets/{arkade-Cyf5MGxG.js.map → arkade-CP4HvKaa.js.map} +1 -1
  13. package/dist/assets/{backup-CqM-0-2v.js → backup-BbO2zLjx.js} +1 -1
  14. package/dist/assets/{backup-CTpFFQ-4.js → backup-BmBtta66.js} +7 -7
  15. package/dist/assets/{backup-CTpFFQ-4.js.map → backup-BmBtta66.js.map} +1 -1
  16. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  17. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  18. package/dist/assets/blobFiles-B0rNnHlO.js +35 -0
  19. package/dist/assets/blobFiles-B0rNnHlO.js.map +1 -0
  20. package/dist/assets/{buttons-BtxwyM2l.js → buttons-YgijrprJ.js} +4 -4
  21. package/dist/assets/{buttons-BtxwyM2l.js.map → buttons-YgijrprJ.js.map} +1 -1
  22. package/dist/assets/buttons-_GBlxAti.js +2 -0
  23. package/dist/assets/{endpoint-BOTFMpfS.js → endpoint-Cl3cgAYl.js} +3 -2
  24. package/dist/assets/endpoint-Cl3cgAYl.js.map +1 -0
  25. package/dist/assets/{envelope-DW8TWKPD.js → envelope-C7vnmsJN.js} +2 -2
  26. package/dist/assets/{envelope-DW8TWKPD.js.map → envelope-C7vnmsJN.js.map} +1 -1
  27. package/dist/assets/{errors-B-zMxbcd.js → errors-BLSPp0tK.js} +3 -1
  28. package/dist/assets/errors-BLSPp0tK.js.map +1 -0
  29. package/dist/assets/{fileBytes-Di7VZtmU.js → fileBytes-B-fqGdEd.js} +3 -3
  30. package/dist/assets/{fileBytes-Di7VZtmU.js.map → fileBytes-B-fqGdEd.js.map} +1 -1
  31. package/dist/assets/{fileBytes-CgbibpbV.js → fileBytes-C2jycN_7.js} +1 -1
  32. package/dist/assets/{fileBytes-CfSXjTFa.js → fileBytes-D6fqFQEX.js} +6 -3
  33. package/dist/assets/fileBytes-D6fqFQEX.js.map +1 -0
  34. package/dist/assets/fileBytesIdb-DOxm_BKt.js +2 -0
  35. package/dist/assets/{fileBytesIdb-D5u6H8X_.js → fileBytesIdb-_HnxdacI.js} +3 -3
  36. package/dist/assets/{fileBytesIdb-D5u6H8X_.js.map → fileBytesIdb-_HnxdacI.js.map} +1 -1
  37. package/dist/assets/{fileBytesOpfs-Dd1N4ERH.js → fileBytesOpfs-p_CANncD.js} +3 -3
  38. package/dist/assets/{fileBytesOpfs-Dd1N4ERH.js.map → fileBytesOpfs-p_CANncD.js.map} +1 -1
  39. package/dist/assets/{groupFiles-uxa0U9_n.js → groupFiles-sIdGq4AV.js} +5 -5
  40. package/dist/assets/groupFiles-sIdGq4AV.js.map +1 -0
  41. package/dist/assets/{host-BbDWamRc.js → host-Drd-c4VO.js} +43 -20
  42. package/dist/assets/host-Drd-c4VO.js.map +1 -0
  43. package/dist/assets/{hyperdhtRelay-De5FT7Am.js → hyperdhtRelay-13aQ4KOf.js} +2 -2
  44. package/dist/assets/{hyperdhtRelay-De5FT7Am.js.map → hyperdhtRelay-13aQ4KOf.js.map} +1 -1
  45. package/dist/assets/{idb-B5L8wuOm.js → idb-CMVgtn2k.js} +59 -6
  46. package/dist/assets/idb-CMVgtn2k.js.map +1 -0
  47. package/dist/assets/{irohWeb-z5QDDWa-.js → irohWeb-DHCxSvHF.js} +2 -2
  48. package/dist/assets/{irohWeb-z5QDDWa-.js.map → irohWeb-DHCxSvHF.js.map} +1 -1
  49. package/dist/assets/{main-_q4Fgypp.js → main-BWAwKY4f.js} +476 -141
  50. package/dist/assets/main-BWAwKY4f.js.map +1 -0
  51. package/dist/assets/{mainline-Cld-bKeC.js → mainline-CsG-DdJ5.js} +2 -2
  52. package/dist/assets/{mainline-Cld-bKeC.js.map → mainline-CsG-DdJ5.js.map} +1 -1
  53. package/dist/assets/{networkReason-vUHo6A_C.js → networkReason-B91upMCb.js} +3 -3
  54. package/dist/assets/{networkReason-vUHo6A_C.js.map → networkReason-B91upMCb.js.map} +1 -1
  55. package/dist/assets/{nwc-zXpmd2FN.js → nwc-BFv67iSz.js} +3 -3
  56. package/dist/assets/{nwc-zXpmd2FN.js.map → nwc-BFv67iSz.js.map} +1 -1
  57. package/dist/assets/{nwcProvider-D482SQ1a.js → nwcProvider-BKNFdH9v.js} +2 -2
  58. package/dist/assets/{nwcProvider-D482SQ1a.js.map → nwcProvider-BKNFdH9v.js.map} +1 -1
  59. package/dist/assets/{profiles-gPCjLOQq.js → profiles-DSxPYdlU.js} +13 -3
  60. package/dist/assets/profiles-DSxPYdlU.js.map +1 -0
  61. package/dist/assets/{registry-CxsDR3c6.js → registry-CHPd_hf7.js} +1 -1
  62. package/dist/assets/{registry-OVx2kY3d.js → registry-qbnWmwCE.js} +3 -3
  63. package/dist/assets/registry-qbnWmwCE.js.map +1 -0
  64. package/dist/assets/{resolve-CPTAnu-9.js → resolve-B63_uSu7.js} +2 -2
  65. package/dist/assets/{resolve-CPTAnu-9.js.map → resolve-B63_uSu7.js.map} +1 -1
  66. package/dist/assets/{restoredRows-Db3oUTko.js → restoredRows-DeBImGRd.js} +2 -2
  67. package/dist/assets/{restoredRows-Db3oUTko.js.map → restoredRows-DeBImGRd.js.map} +1 -1
  68. package/dist/assets/{server-C-EkLCCt.js → server-Cd1dLOB8.js} +1456 -228
  69. package/dist/assets/server-Cd1dLOB8.js.map +1 -0
  70. package/dist/assets/{src-Dg1bvMNv.js → src-DzXL-GYu.js} +7277 -5975
  71. package/dist/assets/src-DzXL-GYu.js.map +1 -0
  72. package/dist/assets/turnRefs-BCtM10d3.js +357 -0
  73. package/dist/assets/turnRefs-BCtM10d3.js.map +1 -0
  74. package/dist/assets/{usdt-BY5OIIvG.js → usdt-B7JWDhQV.js} +3 -3
  75. package/dist/assets/{usdt-BY5OIIvG.js.map → usdt-B7JWDhQV.js.map} +1 -1
  76. package/dist/ghostly.mjs +1 -1
  77. package/package.json +7 -6
  78. package/skills/ghostly/README.md +41 -0
  79. package/skills/ghostly/SKILL.md +79 -0
  80. package/skills/ghostly/scripts/listen +67 -0
  81. package/skills/ghostly/scripts/react +25 -0
  82. package/skills/ghostly/scripts/say +44 -0
  83. package/skills/ghostly/scripts/status +27 -0
  84. package/dist/assets/api-BCrwJ57L.js.map +0 -1
  85. package/dist/assets/apps-DOH1P_S0.js.map +0 -1
  86. package/dist/assets/buttons-CJqNkG-Q.js +0 -2
  87. package/dist/assets/endpoint-BOTFMpfS.js.map +0 -1
  88. package/dist/assets/errors-B-zMxbcd.js.map +0 -1
  89. package/dist/assets/fileBytes-CfSXjTFa.js.map +0 -1
  90. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +0 -2
  91. package/dist/assets/groupFiles-uxa0U9_n.js.map +0 -1
  92. package/dist/assets/host-BbDWamRc.js.map +0 -1
  93. package/dist/assets/idb-B5L8wuOm.js.map +0 -1
  94. package/dist/assets/main-_q4Fgypp.js.map +0 -1
  95. package/dist/assets/profiles-gPCjLOQq.js.map +0 -1
  96. package/dist/assets/registry-OVx2kY3d.js.map +0 -1
  97. package/dist/assets/server-C-EkLCCt.js.map +0 -1
  98. package/dist/assets/src-Dg1bvMNv.js.map +0 -1
  99. package/dist/assets/turnRefs-WnggXm2T.js +0 -323
  100. package/dist/assets/turnRefs-WnggXm2T.js.map +0 -1
@@ -1,27 +1,26 @@
1
1
  import { n as traceJoin } from "./joinTrace-CjRDFy85.js";
2
- import { $c as currentRelays, $i as revokesIdentity, $n as GROUP_FILE_LIMITS, $r as WAKE_CALL_INTERVAL_MS, $t as didDhtFromPublicKey, A as CommunitySession, Aa as forwardedAgain, Ac as devicePingFrame, Ao as rosterAdmin, Ar as newerHead, As as handoffOfferFrame, B as knockRecords, Ba as readReaction, Bc as TURN_MAX, Bi as capsDescriptors, Bn as findEndpoint, Bo as GhostlyHttpError, Br as transferStalled, Bs as readHandoffHave, C as sha256Paced, Ca as parseImageMeta, Cc as DEVICES_CAPABILITY, Ci as readManifest, Cl as randomBytes, Cn as isBitcoinAddress, Cr as beaconRecords, Cs as handoffContext, D as openGroupTyping, Da as RateWindow, Dc as deviceEchoNonce, Dl as utf8Encode, Do as MEMBER_KEY, Dr as lobbyRecords, Ds as handoffEphemeral, E as groupTypingEpoch, Ea as EDIT_RATE_WINDOW_MS, Ec as deviceEchoFrame, El as utf8Decode, Er as lobbyKeys, Es as handoffDigest, Et as normalizeAtprotoHandle, Fa as ReactionWindow, Fc as TURN_RAISE_READ_TIMEOUT_MS, Fr as readLobby, Fs as handoffVersions, G as groupWakeFrame, Ga as replySnippet, Gc as signTurnRelease, Gn as INVITE_TAKEN, Go as decodeControl, Gr as typingActivity, Gs as sealHandoffPiece, H as readKnocks, Ha as wireReaction, Hc as nextTurnPosition, Hn as parseCashuRequestPayload, Ho as parseLocalTarget, Hs as readHandoffPake, I as decodeGroupEntryLink, Ia as nextReactionNumber, Ic as TURN_SETTLE_MS, In as decodeBolt11, Io as DHT_POLL_INTERVALS, Ir as shouldBeHub, Is as newHandoffId, J as GROUP_LIMITS, Jc as turnName, Ji as emptyIdentityLedger, Jn as pairedMessageFits, Jo as RTC_CONFIG, Jr as mayPin, Js as readSetUpdate, K as groupWakes, Ka as BUTTON_ID, Kc as turnInstance, Ki as IdentityExchange, Kn as LIVENESS_PING_MS, Ko as encodeControl, L as encodeGroupEntryLink, La as queueReaction, Lc as classifyTurnRead, Ln as ENDPOINT, Lr as ChatFiles, Ls as readHandoffAck, M as communityHasFrame, Ma as COMMUNITY_REACTION_FRAME, Mc as seedSigner, Mo as verifyCommitSignature, Mr as rankHubs, Ms as handoffReleaseFrame, N as decodeCommunityLink, Na as GROUP_REACTION_FRAME, Nc as webCryptoSigner, No as edgeParams, Nr as readBeacon, Ns as handoffRetryAfter, O as sealGroupTyping, Oa as dhtEditId, Oc as deviceKeyZ32, Oo as commitHash, Or as mergeBeacon, Os as handoffHelloFrame, P as encodeCommunityLink, Pa as REACTION_LIMITS, Po as DirectPathWatch, Pr as readBeaconHead, Ps as handoffStreamKey, Q as frameBytes, Qc as RelayTransport, Qi as receivedIdentityStatus, Qn as parseEngineError, Qr as readPin, Qs as verifySetUpdate, Qt as didDhtDocument, R as entryParams, Ra as reactionEmoji, Rc as turnPutSummary, Ri as CapsExchange, Rn as cashuRequestPayload, Ro as RELAY_POLL_INTERVALS, Rr as FILE_LIMITS, Rs as readHandoffCancel, S as readAppBundleAsync, Sa as parseVideoMeta, Si as newMailbox, Sn as sparkInvoiceDetails, So as canonicalJson, Sr as beaconKeys, Ss as handoffCancelFrame, T as GroupTypingBoard, Ta as EDIT_BUFFER_MS, Tc as HANDOFF_CAPABILITY, Tl as toZ32, Tn as isPubkyKey, To as GROUP_MEMBER_CAP, Tr as freshHubs, Ts as handoffDataFrame, Tt as isAtprotoDid, Ua as pairedReplyAuthor, Uc as readTurnPacket, Un as parseFedimintRequestPayload, Uo as webLocalFetch, Ur as TypingSender, Us as readHandoffTurnFrame, V as mergeKnocks, Va as validReactionNumber, Vc as firstDeviceSetSecret, Vi as dialDescriptors, Vn as isFederationId, Vo as formatLocalTarget, Vs as readHandoffHello, W as GroupWakeLimiter, Wa as readReply, Wc as signTurnPacket, Wi as IDENTITY_REVOCATION_LABEL, Wn as GhostLink, Wo as LIMITS, Ws as readHandoffVerified, X as GROUP_READ_NOTE, Xc as turnVerifierMark, Xi as identityStatement, Xn as engineError, Xr as pinIsNewer, Xs as setUpdateFrame, Yc as turnStatedVerifier, Yi as identityRevocationValue, Yn as pairedMessageFrame, Yr as nextPinNumber, Ys as setAckFrame, Z as GroupSession, Za as STATUS_CARD_LIMITS, Zc as DEFAULT_RELAYS, Zi as newIdentityBinding, Zn as engineText, Zr as pinNumberHolds, Zs as signTombstone, _c as createChatInvite, _i as HOLD_DATED_BACK_MS, _n as validatePaymentTarget, _o as isAppRef, _r as MESH_HUBS, _s as filePartName, a as planAppUpdate, aa as automaticTransport, ac as enrollDigits, ai as wakeRequest, al as withRequestOptions, an as EVM_TEST_CHAINS, ao as arrivalKey, as as serviceIdFromName, at as groupName, b as pacer, ba as parseVoiceMeta, bi as HoldRefusedError, bl as bytesEqual, bn as SPARK_PROVIDER, br as pickMeshHubs, bs as handoffAttemptSucceeded, cc as enrollHelloFrame, cl as createRelayPayload, cn as SEPOLIA_TEST_USDT, co as receivedTimestamp, cs as HANDOFF_DONE, d as APP_BUNDLE_LIMITS, da as DHT_FILE_CAPABILITY, dc as readEnrollHello, di as APPS_CAPABILITY, dn as USDT_PUBLIC_RPC, do as APP_STATEMENT_LIMITS, ds as HANDOFF_OFFER, ea as lastSharedWithMe, ei as WakeLimiter, el as normalizeRelayUrl, en as encodeDidDhtDeactivated, es as parseCallSignal, f as APP_BUNDLE_MAGIC, fa as dhtFileId, fc as verifyEnrollProof, fi as BUTTONS_CAPABILITY, fl as createIdentity, fn as WALLET_NETWORKS, fo as appFingerprint, fs as HANDOFF_PAKE, g as appViewOf, gc as OwnInviteError, gi as HOLD_CAPABILITY, gl as verify, gn as formatPaymentAmount, go as isAppKey, gr as readGroupSignal, gs as HANDOFF_VERIFIED, h as appDigest, ha as sanitizeMime, hc as readDeviceInvite, hi as UPGRADE_CAPABILITY, hl as publicKeyFromZ32, hn as assertWholeSats, ho as isAppHash, hr as groupSignalFrame, hs as HANDOFF_TIMINGS, i as checkAppBeforeRun, ia as TRANSPORTS, ic as enrollCancelReason, ii as relayRequest, il as isDiscoveryBudgetError, in as ETHEREUM_USDT, io as statusCardText, ir as groupFileFallback, is as LEGACY_SERVICES, it as mentionsMember, j as GROUP_READ_NOTE_COMMUNITY, ja as readForwarded, jc as newDeviceSetSecret, jo as rosterHas, jr as pickHubs, js as handoffPakeFrame, k as COMMUNITY_LIMITS, ka as parseLinkPreview, kc as deviceLinkPairing, ko as parseGroupCommit, kr as mergeLobby, ks as handoffManifestFrames, l as readAppStoreSteps, la as DHT_MESSAGE_TTL, lc as enrollProofFrame, li as chatAppId, lo as shownTime, ls as HANDOFF_HAVE, ma as sanitizeFileName, mc as deviceInviteJoinerParams, mi as STATUS_CARD_CAPABILITY, ml as identityFromSeedB64, mn as assertTokenUnits, ms as HANDOFF_REQUEST, n as appStoreDecision, na as emptyProofLedger, ni as newWakeToken, nl as traceLink, no as checkStatusCard, ns as emptyLinkRecords, nt as carryMentions, o as APP_STORE_LIMITS, oc as enrollDoneFrame, oi as checkPushEndpoint, ol as PacketTooLargeError, on as ONCHAIN_PROVIDER, oo as claimedTime, or as readGroupFileMeta, os as HANDOFF_ACK, p as APP_ICON_PATH, pa as formatFileSize, pc as createDeviceInvite, pi as EDIT_CAPABILITY, pl as identityFromSeed, pn as arkAddressPrefix, po as appRef, ps as HANDOFF_PIECE_BYTES, q as parseGroupWakeFrame, qc as turnKeys, qn as PAIRED_FRAME_TOO_LARGE, qr as PIN_LIMITS, r as appUpdateDecision, ra as validExternalKey, rc as enrollCancelFrame, ri as parseWakeFrame, rl as budgetRetryMs, rn as signDidDhtPacket, ro as sameButtonText, rs as HTTP_SERVICE_PROTO, rt as parseCommunityEdit, sc as enrollGrantFrame, si as vapidKeysMatch, sn as PaymentPreflightError, so as heardTime, ss as HANDOFF_CANCEL, t as appRevocationFor, ta as PeerProofs, ti as deviceWakeRequest, tn as encodeDidDhtPacket, tt as GROUP_EDIT_TEXT_BYTES, ua as peekDhtMailbox, uc as readEnrollGrant, ui as isAppVersion, un as TEST_USDT_FAUCET_AMOUNT, ur as CarriedTransport, us as HANDOFF_HELLO, vc as decodeInviteCode, vi as HOLD_LIMITS, vl as sanitizeAvatar, vn as walletNetworkOf, vo as readAppRevocation, vr as mayBeHub, vs as handoffAttemptAllowed, wc as ENROLL_CAPABILITY, wl as toBase64Url, wo as readCanonicalJson, wr as doorHubs, ws as handoffContextProof, xc as inviteOwnership, xi as isHoldUrl, xl as fromBase64Url, xn as sparkAddressKind, xr as COMMUNITY_TOPOLOGY, xs as handoffBusyFrame, y as isAppUrl, ya as fileMessageText, yc as encodeInviteCode, yi as HoldKeys, yl as sanitizeDisplayText, yn as SPARK_NETWORKS, yr as meshHubs, ys as handoffAttemptFailed, z as knockIdentity, za as reactionIsNewer, zc as TURN_LAST_SEQUENCE, zi as DHT_TEXT_CAPABILITY, zn as fedimintRequestPayload, zo as presenceSeenAt, zr as transferEnded, zs as readHandoffDone } from "./src-Dg1bvMNv.js";
2
+ import { $a as GROUP_MEMBER_CAP, $c as DEVICES_CAPABILITY, $i as DHT_MESSAGE_TTL, $l as randomBytes, $o as handoffContextProof, $r as chatAppId, $s as knockSlotIdentity, A as knockRecords, Aa as wireReaction, Ac as isAppRef, Ai as capsDescriptors, Al as normalizeRelayUrl, An as findEndpoint, Ar as transferStalled, B as GROUP_READ_NOTE, Bc as createContactLink, Bi as identityStatement, Bn as engineError, Bo as HANDOFF_OFFER, Br as pinIsNewer, Bs as readEnrollHello, C as decodeCommunityLink, Ca as ReactionWindow, Cl as turnKeys, Co as RTC_CONFIG, Cr as readBeacon, Cs as readSetUpdate, D as encodeGroupEntryLink, Da as reactionIsNewer, Dl as DEFAULT_RELAYS, Dn as ENDPOINT, Dr as ChatFiles, Ds as verifySetUpdate, E as decodeGroupEntryLink, Ea as reactionEmoji, Ec as appRef, El as turnVerifierMark, En as decodeBolt11, Er as shouldBeHub, Es as signTombstone, F as groupWakeFrame, Fc as parseImageMeta, Fl as withRequestOptions, Fn as INVITE_TAKEN, Fo as HANDOFF_ACK, Fr as typingActivity, Fs as enrollDoneFrame, G as GROUP_EDIT_TEXT_BYTES, Ga as sameButtonText, Gc as readDeviceInvite, Gi as PeerProofs, Gl as publicKeyFromZ32, Go as HANDOFF_VERIFIED, Gr as deviceWakeRequest, Gs as newInbox, Gt as encodeDidDhtPacket, H as frameBytes, Hc as readContactLink, Hi as receivedIdentityStatus, Hl as createIdentity, Hn as parseEngineError, Ho as HANDOFF_PIECE_BYTES, Hr as readPin, Hs as clearMissed, Ht as didDhtDocument, I as groupWakes, Ii as IdentityExchange, Il as PacketTooLargeError, In as LIVENESS_PING_MS, Io as HANDOFF_CANCEL, Is as enrollGrantFrame, J as mentionsMember, Ja as claimedTime, Jc as decodeInviteCode, Ji as TRANSPORTS, Jl as sanitizeAvatar, Jn as groupFileFallback, Jo as handoffAttemptFailed, Jr as relayRequest, Js as takeRequest, Jt as ETHEREUM_USDT, K as carryMentions, Ka as statusCardText, Kc as OwnInviteError, Ki as emptyProofLedger, Kl as verify, Ko as filePartName, Kr as newWakeToken, Ks as planKnocks, L as parseGroupWakeFrame, Lc as canonicalJson, Ln as PAIRED_FRAME_TOO_LARGE, Lo as HANDOFF_DONE, Lr as PIN_LIMITS, Ls as enrollHelloFrame, M as readKnocks, Ma as readReply, Ml as traceLink, Mn as parseCashuRequestPayload, Mo as HTTP_SERVICE_PROTO, Ms as enrollCancelFrame, Na as replySnippet, Nl as budgetRetryMs, Nn as parseFedimintRequestPayload, No as LEGACY_SERVICES, Nr as TypingSender, Ns as enrollCancelReason, O as entryParams, Oa as readReaction, Oc as isAppHash, Oi as CapsExchange, Ol as RelayTransport, On as cashuRequestPayload, Or as FILE_LIMITS, P as GroupWakeLimiter, Pa as BUTTON_ID, Pi as IDENTITY_REVOCATION_LABEL, Pl as isDiscoveryBudgetError, Pn as GhostLink, Po as serviceIdFromName, Ps as enrollDigits, Qa as GROUP_ADMIN_OFF_ERROR, Qo as handoffContext, Qs as contactDay, Qt as SEPOLIA_TEST_USDT, R as GROUP_LIMITS, Ri as emptyIdentityLedger, Rl as createRelayPayload, Rn as pairedMessageFits, Ro as HANDOFF_HAVE, Rr as mayPin, Rs as enrollProofFrame, S as communityHasFrame, Sa as REACTION_LIMITS, Sc as sha256Paced, Sl as turnInstance, Sr as rankHubs, Ta as queueReaction, Tc as appFingerprint, Tl as turnStatedVerifier, Tr as readLobby, Ts as setUpdateFrame, Uc as createDeviceInvite, Ui as revokesIdentity, Ul as identityFromSeed, Un as GROUP_FILE_LIMITS, Uo as HANDOFF_REQUEST, Ur as WAKE_CALL_INTERVAL_MS, Us as endedRequests, Ut as didDhtFromPublicKey, V as GroupSession, Vc as encodeContactLink, Vi as newIdentityBinding, Vn as engineText, Vo as HANDOFF_PAKE, Vr as pinNumberHolds, Vs as verifyEnrollProof, Wa as checkStatusCard, Wc as deviceInviteJoinerParams, Wi as lastSharedWithMe, Wl as identityFromSeedB64, Wo as HANDOFF_TIMINGS, Wr as WakeLimiter, Ws as inboxRecord, Wt as encodeDidDhtDeactivated, Xa as receivedTimestamp, Xc as inviteLink, Xl as bytesEqual, Xn as readGroupFileMeta, Xo as handoffBusyFrame, Xr as checkPushEndpoint, Xs as ContactRequestError, Xt as ONCHAIN_PROVIDER, Y as groupName, Ya as heardTime, Yc as encodeInviteCode, Yi as automaticTransport, Yl as sanitizeDisplayText, Yo as handoffAttemptSucceeded, Yr as wakeRequest, Ys as turnOff, Yt as EVM_TEST_CHAINS, Za as shownTime, Zc as inviteOwnership, Zl as fromBase64Url, Zo as handoffCancelFrame, Zr as vapidKeysMatch, Zs as REQUEST_LIFETIME_MS, Zt as PaymentPreflightError, _ as openGroupTyping, _a as parseLinkPreview, _l as firstDeviceSetSecret, _o as parseLocalTarget, _r as lobbyRecords, _s as readHandoffPake, a as appStoreDecision, aa as sanitizeMime, ac as openRequest, ai as UPGRADE_CAPABILITY, al as deviceLinkPairing, an as assertWholeSats, ao as rosterHas, ar as groupSignalFrame, as as handoffOfferFrame, b as CommunitySession, ba as COMMUNITY_REACTION_FRAME, bl as signTurnPacket, bo as decodeControl, br as newerHead, bs as sealHandoffPiece, c as planAppUpdate, cc as requestRecords, ci as HOLD_LIMITS, cl as seedSigner, cn as walletNetworkOf, co as DirectPathWatch, cr as mayBeHub, cs as handoffRetryAfter, dc as APP_BUNDLE_MAGIC, di as isHoldUrl, dl as TURN_RAISE_READ_TIMEOUT_MS, dn as sparkAddressKind, dr as COMMUNITY_TOPOLOGY, ds as newHandoffId, ea as peekDhtMailbox, ec as knockSlotOf, ei as isAppVersion, el as ENROLL_CAPABILITY, en as TEST_USDT_FAUCET_AMOUNT, er as CarriedTransport, es as handoffDataFrame, eu as toBase64Url, f as readAppStoreSteps, fa as parseVideoMeta, fc as APP_ICON_PATH, fi as newMailbox, fl as TURN_SETTLE_MS, fn as sparkInvoiceDetails, fo as RELAY_POLL_INTERVALS, fr as beaconKeys, fs as readHandoffAck, g as groupTypingEpoch, ga as dhtEditId, gl as TURN_MAX, go as formatLocalTarget, gr as lobbyKeys, gs as readHandoffHello, gt as normalizeAtprotoHandle, h as GroupTypingBoard, ha as RateWindow, hc as appViewOf, hl as TURN_LAST_SEQUENCE, hn as isPubkyKey, ho as GhostlyHttpError, hr as freshHubs, hs as readHandoffHave, ht as isAtprotoDid, i as appRevocationFor, ia as sanitizeFileName, ic as mergeContactKnocks, ii as STATUS_CARD_CAPABILITY, il as deviceKeyZ32, in as assertTokenUnits, io as rosterAdmin, is as handoffManifestFrames, j as mergeKnocks, ja as pairedReplyAuthor, jc as readAppRevocation, ji as dialDescriptors, jn as isFederationId, jo as emptyLinkRecords, k as knockIdentity, ka as validReactionNumber, kc as isAppKey, ki as DHT_TEXT_CAPABILITY, kl as currentRelays, kn as fedimintRequestPayload, ko as parseCallSignal, kr as transferEnded, l as APP_STORE_LIMITS, la as fileMessageText, lc as seenEntry, li as HoldKeys, ll as webCryptoSigner, ln as SPARK_NETWORKS, lr as meshHubs, ls as handoffStreamKey, ma as EDIT_RATE_WINDOW_MS, mc as appDigest, ml as turnPutSummary, mo as presenceSeenAt, mr as doorHubs, ms as readHandoffDone, n as readX401SdJwtVc, na as dhtFileId, nc as linkRecords, ni as BUTTONS_CAPABILITY, nl as deviceEchoFrame, nn as WALLET_NETWORKS, no as commitHash, ns as handoffEphemeral, nu as utf8Decode, o as appUpdateDecision, oc as readKnockSlot, oi as HOLD_CAPABILITY, ol as devicePingFrame, on as formatPaymentAmount, oo as verifyCommitSignature, or as readGroupSignal, os as handoffPakeFrame, pa as EDIT_BUFFER_MS, pi as readManifest, pl as classifyTurnRead, pn as isBitcoinAddress, po as WATCH_PEER_MS, pr as beaconRecords, ps as readHandoffCancel, q as parseCommunityEdit, qa as arrivalKey, qc as createChatInvite, qi as validExternalKey, qo as handoffAttemptAllowed, qr as parseWakeFrame, qs as settleRequest, qt as signDidDhtPacket, r as sameX401HolderJwk, ra as formatFileSize, rc as makeStampPaced, ri as EDIT_CAPABILITY, rl as deviceEchoNonce, rn as arkAddressPrefix, ro as parseGroupCommit, rs as handoffHelloFrame, ru as utf8Encode, s as checkAppBeforeRun, sc as readLinkRecord, si as HOLD_DATED_BACK_MS, sl as newDeviceSetSecret, sn as validatePaymentTarget, so as edgeParams, sr as MESH_HUBS, ss as handoffReleaseFrame, t as newX401HolderKey, ta as DHT_FILE_CAPABILITY, tc as knockSlotRecords, ti as APPS_CAPABILITY, tl as HANDOFF_CAPABILITY, tn as USDT_PUBLIC_RPC, to as MEMBER_KEY, ts as handoffDigest, tu as toZ32, ua as parseVoiceMeta, uc as APP_BUNDLE_LIMITS, ui as HoldRefusedError, un as SPARK_PROVIDER, uo as DHT_POLL_INTERVALS, ur as pickMeshHubs, us as handoffVersions, v as sealGroupTyping, va as forwardedAgain, vc as isAppUrl, vl as nextTurnPosition, vo as webLocalFetch, vr as mergeBeacon, vs as readHandoffTurnFrame, w as encodeCommunityLink, wa as nextReactionNumber, wc as APP_STATEMENT_LIMITS, wl as turnName, wr as readBeaconHead, ws as setAckFrame, x as GROUP_READ_NOTE_COMMUNITY, xa as GROUP_REACTION_FRAME, xc as readAppBundleAsync, xl as signTurnRelease, xo as encodeControl, xr as pickHubs, y as COMMUNITY_LIMITS, ya as readForwarded, yc as pacer, yl as readTurnPacket, yo as LIMITS, yr as mergeLobby, ys as readHandoffVerified, za as STATUS_CARD_LIMITS, zc as readCanonicalJson, zi as identityRevocationValue, zn as pairedMessageFrame, zo as HANDOFF_HELLO, zr as nextPinNumber, zs as readEnrollGrant } from "./src-DzXL-GYu.js";
3
3
  import { i as SourceUnreachableError, n as PROVIDER_PLATFORMS, o as isNothingSpentError, r as SourceConfigError, t as NothingSpentError, u as redact } from "./types-C4_KafL_.js";
4
4
  import { t as bitcoindRpc } from "./bitcoindProvider-DGw3xBSa.js";
5
- import { t as nwc } from "./nwcProvider-D482SQ1a.js";
6
- import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-B5L8wuOm.js";
7
- import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-vUHo6A_C.js";
8
- import { $ as setSingleWriterGate, A as LightningCards, B as breezDatabaseInUse, C as FileAppender, D as streamStored, E as storedSize, F as ProviderSources, G as ModeChanged, H as dropBreezDatabase, I as sourceKey, J as networkLabel, K as ModeGate, L as CASHU_MINT_SOURCE, M as isRecoveryPhrase, N as normalizePhrase, P as lightningPaying, Q as setAwayWallets, R as cashuMint, S as WALLET_TYPES, T as removeStored, U as iceServerProblem, V as breezDatabasesFor, W as iceServerSetting, X as refuseAway, Y as awayFrom, Z as requireTurn, _ as crossNetwork, a as storedGroupFiles, b as walletInstances, c as receivedPairedReply, d as paymentAlias, f as paymentWireId, g as createTiming, h as createFailure, i as groupOfFile, j as cardsKey, k as CASHU_CARD, l as replyRef, m as assertConfirmedReal, n as groupFileId, o as groupReply, p as WALLET_NAMES, q as WrongNetworkError, r as groupFileText, s as pairedWireReply, t as GroupFileDesk, u as replyTo, v as paymentNetwork, w as readStored$1, y as paymentNetworksOf, z as breezDatabase } from "./groupFiles-uxa0U9_n.js";
9
- import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb-z5QDDWa-.js";
5
+ import { t as nwc } from "./nwcProvider-BKNFdH9v.js";
6
+ import { a as clearProfileStores, c as errorLine, d as openDb, f as profileOpenFailure, h as wrap, i as STORES$1, l as fileStore, m as transact, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as store, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as newestWalletTx } from "./idb-CMVgtn2k.js";
7
+ import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-B91upMCb.js";
8
+ import { $ as setSingleWriterGate, A as LightningCards, B as breezDatabaseInUse, C as FileAppender, D as streamStored, E as storedSize, F as ProviderSources, G as ModeChanged, H as dropBreezDatabase, I as sourceKey, J as networkLabel, K as ModeGate, L as CASHU_MINT_SOURCE, M as isRecoveryPhrase, N as normalizePhrase, P as lightningPaying, Q as setAwayWallets, R as cashuMint, S as WALLET_TYPES, T as removeStored, U as iceServerProblem, V as breezDatabasesFor, W as iceServerSetting, X as refuseAway, Y as awayFrom, Z as requireTurn, _ as crossNetwork, a as storedGroupFiles, b as walletInstances, c as receivedPairedReply, d as paymentAlias, f as paymentWireId, g as createTiming, h as createFailure, i as groupOfFile, j as cardsKey, k as CASHU_CARD, l as replyRef, m as assertConfirmedReal, n as groupFileId, o as groupReply, p as WALLET_NAMES, q as WrongNetworkError, r as groupFileText, s as pairedWireReply, t as GroupFileDesk, u as replyTo, v as paymentNetwork, w as readStored$1, y as paymentNetworksOf, z as breezDatabase } from "./groupFiles-sIdGq4AV.js";
9
+ import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb-DHCxSvHF.js";
10
10
  import { c as isTestMint, d as paysItsOwnInvoices, l as isWorthlessMint, n as DEFAULT_MINTS, o as fakesLightning, r as TEST_MINT, t as BITCOIN_INVOICE_ON_TESTNET, u as mintNetwork } from "./mints-BrlY773j.js";
11
- import { _ as reserveAdapterIds, b as testBuild, f as setOwnDidSource, g as registeredOnchainProviders, h as registeredLightningProviders, l as chosenResolver, n as resolveAtprotoDid, p as onAdaptersChanged, t as assertPublicServer, v as boundedIdentityFetch, y as verifyIdentity } from "./resolve-CPTAnu-9.js";
12
- import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-Di7VZtmU.js";
13
- import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-DW8TWKPD.js";
11
+ import { _ as reserveAdapterIds, b as testBuild, f as setOwnDidSource, g as registeredOnchainProviders, h as registeredLightningProviders, l as chosenResolver, n as resolveAtprotoDid, p as onAdaptersChanged, t as assertPublicServer, v as boundedIdentityFetch, y as verifyIdentity } from "./resolve-B63_uSu7.js";
12
+ import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-B-fqGdEd.js";
13
+ import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-C7vnmsJN.js";
14
14
  import { t as hyperdhtRelayProblem } from "./hyperdhtRelay-BT_wRxab.js";
15
15
  import { sha256 } from "@noble/hashes/sha2.js";
16
16
  import { hkdf } from "@noble/hashes/hkdf.js";
17
17
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
18
- import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
19
18
  import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
19
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
20
20
  import { secp256k1 } from "@noble/curves/secp256k1.js";
21
21
  import { verifyEvent } from "nostr-tools/pure";
22
22
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
23
23
  import { wordlist } from "@scure/bip39/wordlists/english.js";
24
- import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
25
24
  import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
26
25
  /** Evidence older than this says nothing about the clock now. */
27
26
  var CLOCK_EVIDENCE_MS = 18e5;
@@ -199,7 +198,7 @@ function viewNews(before, after) {
199
198
  //#endregion
200
199
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
201
200
  /** The Ethereum libraries, a large part of the app: loaded once a USDT wallet is made, opened or restored, not with the engine. */
202
- var loadUsdt = () => import("./usdt-BY5OIIvG.js");
201
+ var loadUsdt = () => import("./usdt-B7JWDhQV.js");
203
202
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
204
203
  var DEFAULT_USDT = {
205
204
  network: "ethereum",
@@ -648,7 +647,7 @@ async function restoreArkDatabase(walletId, snapshot) {
648
647
  //#endregion
649
648
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
650
649
  /** The Ark SDK, a large part of the app: loaded once an Ark wallet is made, opened or restored, not with the engine. */
651
- var loadArkade = () => import("./arkade-Cyf5MGxG.js");
650
+ var loadArkade = () => import("./arkade-CP4HvKaa.js");
652
651
  /** How long a Testnet wallet about to make way for another waits for its server to say what it holds. */
653
652
  var RETIRE_READ_MS = 1e4;
654
653
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
@@ -3556,7 +3555,7 @@ var CashuAdapter = class {
3556
3555
  token = await this.wallet.executeReviewedCashu(review, prepared);
3557
3556
  } catch (error) {
3558
3557
  prepared.attemptEndedAt = Date.now();
3559
- if (persist && isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
3558
+ if (persist && (await import("@cashu/cashu-ts")).isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
3560
3559
  throw error;
3561
3560
  }
3562
3561
  await persist?.();
@@ -6703,7 +6702,7 @@ var registry;
6703
6702
  var loading$1;
6704
6703
  var listeners = /* @__PURE__ */ new Set();
6705
6704
  function loadIdentityProviders() {
6706
- return loading$1 ??= import("./registry-CxsDR3c6.js").then((m) => {
6705
+ return loading$1 ??= import("./registry-CHPd_hf7.js").then((m) => {
6707
6706
  registry = m;
6708
6707
  for (const listener of listeners) listener();
6709
6708
  return m;
@@ -7132,16 +7131,176 @@ var IdentityProofs = class {
7132
7131
  }
7133
7132
  };
7134
7133
  //#endregion
7134
+ //#region ../browser/src/engine/x401.ts
7135
+ var X401_KEY = "x401Credentials";
7136
+ var MAX_CREDENTIALS = 32;
7137
+ /** Keys waiting for an issuer: the oldest goes when one more is made. */
7138
+ var MAX_WAITING_KEYS = 8;
7139
+ var ASK_FOR_A_NEW_KEY = "ask the issuer to bind it to a key from x401 key new";
7140
+ var LABEL_CHARS = 80;
7141
+ var newId$1 = () => toBase64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12)));
7142
+ /** The paths of an object's members, nested objects' own members included. */
7143
+ function paths(value, at = []) {
7144
+ return Object.entries(value).flatMap(([key, item]) => {
7145
+ const path = [...at, key];
7146
+ return typeof item === "object" && item !== null && !Array.isArray(item) ? [path, ...paths(item, path)] : [path];
7147
+ });
7148
+ }
7149
+ /** The record's retired keys with `more` after them: the latest `MAX_RETIRED_KEYS`. */
7150
+ var retire = (stored, more) => [...stored.retired ?? [], ...more].slice(-32);
7151
+ /** Why a credential's key is not one waiting for it: what became of it, when this profile made it. */
7152
+ function notWaiting(stored, holder) {
7153
+ const held = stored.credentials.find((c) => sameX401HolderJwk(c.holderKey.jwk, holder));
7154
+ if (held) return `This credential's key already holds credential ${held.id} (${held.label}): a key takes one credential`;
7155
+ const retired = [...stored.retired ?? []].reverse().find((r) => sameX401HolderJwk(r.jwk, holder));
7156
+ if (retired?.credential) return `This credential's key went with credential ${retired.credential} when it was removed`;
7157
+ if (retired) return `This credential's key was dropped: a profile keeps the ${MAX_WAITING_KEYS} newest keys waiting for a credential`;
7158
+ return "This credential is bound to a key this profile did not make";
7159
+ }
7160
+ /**
7161
+ * x401 credentials (WISP 3xx), engine side: the profile's holder keys and the credentials issuers bound to them, in
7162
+ * the settings store beside the identity proofs. Read from the store on each call: nothing of it stays in memory.
7163
+ * Nothing here is shared with a contact, and no presentation is made here yet.
7164
+ */
7165
+ var X401Credentials = class {
7166
+ /** Changes one after another: each reads the record and writes it back, and two at once would lose one. */
7167
+ queue = Promise.resolve();
7168
+ serial(change) {
7169
+ const next = this.queue.then(change, change);
7170
+ this.queue = next.catch(() => {});
7171
+ return next;
7172
+ }
7173
+ async load() {
7174
+ return await wrap((await store(STORES$1.settings, "readonly")).get("x401Credentials")) ?? {
7175
+ keys: [],
7176
+ credentials: []
7177
+ };
7178
+ }
7179
+ async save(value) {
7180
+ await transact([STORES$1.settings], (stores) => {
7181
+ stores[STORES$1.settings].put(value, X401_KEY);
7182
+ });
7183
+ }
7184
+ /**
7185
+ * A fresh P-256 holder key, kept until a credential bound to it is added. Its public JWK goes to the issuer. A profile
7186
+ * keeps the 8 newest keys waiting: past them, the oldest is dropped, and `dropped` names it.
7187
+ */
7188
+ async newKey() {
7189
+ const { secret, jwk } = newX401HolderKey();
7190
+ const deviceKey = newDeviceKey();
7191
+ const key = {
7192
+ id: newId$1(),
7193
+ jwk,
7194
+ secret: {
7195
+ sealed: await sealSeed(toBase64Url(secret), deviceKey),
7196
+ deviceKey
7197
+ },
7198
+ createdAt: Date.now()
7199
+ };
7200
+ secret.fill(0);
7201
+ return this.serial(async () => {
7202
+ const stored = await this.load();
7203
+ const keys = [...stored.keys, key];
7204
+ const dropped = keys.slice(0, -8);
7205
+ await this.save({
7206
+ ...stored,
7207
+ keys: keys.slice(-8),
7208
+ retired: retire(stored, dropped.map((k) => ({ jwk: k.jwk })))
7209
+ });
7210
+ return {
7211
+ id: key.id,
7212
+ jwk,
7213
+ ...dropped.length ? { dropped: dropped[0].id } : {}
7214
+ };
7215
+ });
7216
+ }
7217
+ /**
7218
+ * Adds an issued credential bound to one of the keys `newKey` made. Refused: a credential that does not read, one
7219
+ * that has expired, one bound to another key, one already kept, a label past `LABEL_CHARS` (it was cut without a
7220
+ * word). A label is one line: its line breaks and tabs read as one space, as a group's name.
7221
+ */
7222
+ add({ credential, label: given }) {
7223
+ const label = given?.replace(/\s+/g, " ").trim();
7224
+ if (label && ([...label].length > LABEL_CHARS || /\p{Cc}/u.test(label))) return Promise.reject(/* @__PURE__ */ new Error(`A credential's label is up to ${LABEL_CHARS} characters on one line`));
7225
+ const read = readX401SdJwtVc(credential);
7226
+ if (!read.ok) return Promise.reject(/* @__PURE__ */ new Error(`Not an SD-JWT VC Ghostly can keep: ${read.reason}`));
7227
+ const vc = read.value;
7228
+ if (vc.expiresAt !== void 0 && vc.expiresAt * 1e3 <= Date.now()) return Promise.reject(/* @__PURE__ */ new Error(`This credential expired on ${(/* @__PURE__ */ new Date(vc.expiresAt * 1e3)).toISOString()}`));
7229
+ return this.serial(() => this.keep(credential.trim(), vc, label));
7230
+ }
7231
+ async keep(credential, vc, label) {
7232
+ const stored = await this.load();
7233
+ if (stored.credentials.some((c) => c.credential === credential)) throw new Error("This credential is kept already");
7234
+ const holderKey = stored.keys.find((k) => sameX401HolderJwk(k.jwk, vc.holder));
7235
+ if (!holderKey) throw new Error(`${notWaiting(stored, vc.holder)}: ${ASK_FOR_A_NEW_KEY}`);
7236
+ if (stored.credentials.length >= MAX_CREDENTIALS) throw new Error(`A profile keeps at most ${MAX_CREDENTIALS} credentials: remove one first`);
7237
+ const kept = {
7238
+ id: newId$1(),
7239
+ label: label || vc.type.slice(0, LABEL_CHARS),
7240
+ format: vc.format,
7241
+ credential,
7242
+ type: vc.type,
7243
+ issuer: vc.issuer,
7244
+ holderKey,
7245
+ addedAt: Date.now(),
7246
+ ...vc.expiresAt !== void 0 ? { expiresAt: vc.expiresAt * 1e3 } : {}
7247
+ };
7248
+ await this.save({
7249
+ ...stored,
7250
+ keys: stored.keys.filter((k) => k !== holderKey),
7251
+ credentials: [...stored.credentials, kept]
7252
+ });
7253
+ return this.viewOf(kept, false);
7254
+ }
7255
+ async list({ values = false } = {}) {
7256
+ return (await this.load()).credentials.map((c) => this.viewOf(c, values));
7257
+ }
7258
+ /** Removes a credential and its holder key. False when the profile has no credential with that id. */
7259
+ remove({ id }) {
7260
+ return this.serial(async () => {
7261
+ const stored = await this.load();
7262
+ const gone = stored.credentials.find((c) => c.id === id);
7263
+ if (!gone) return false;
7264
+ await this.save({
7265
+ ...stored,
7266
+ credentials: stored.credentials.filter((c) => c !== gone),
7267
+ retired: retire(stored, [{
7268
+ jwk: gone.holderKey.jwk,
7269
+ credential: id
7270
+ }])
7271
+ });
7272
+ return true;
7273
+ });
7274
+ }
7275
+ viewOf(c, values) {
7276
+ const read = readX401SdJwtVc(c.credential);
7277
+ const claims = read.ok ? read.value.claims : {};
7278
+ return {
7279
+ id: c.id,
7280
+ label: c.label,
7281
+ format: c.format,
7282
+ type: c.type,
7283
+ issuer: c.issuer,
7284
+ addedAt: c.addedAt,
7285
+ expiresAt: c.expiresAt ?? null,
7286
+ notBefore: read.ok && read.value.notBefore !== void 0 ? read.value.notBefore * 1e3 : null,
7287
+ claims: paths(claims),
7288
+ disclosable: read.ok ? read.value.disclosable : [],
7289
+ ...values ? { values: claims } : {}
7290
+ };
7291
+ }
7292
+ };
7293
+ //#endregion
7135
7294
  //#region ../browser/src/engine/did.ts
7136
7295
  var KEY$1 = "profileDid";
7137
7296
  /** Mainline keeps a mutable item about two hours: the same packet goes out again every hour. */
7138
7297
  var DID_REPUBLISH_MS = 36e5;
7139
7298
  /** After start, once the chats had their first go at the relays. */
7140
- var FIRST_PUBLISH_MS = 15e3;
7299
+ var FIRST_PUBLISH_MS$1 = 15e3;
7141
7300
  /** A few switches flipped in a row make one publish. */
7142
7301
  var CHANGE_DELAY_MS = 2e3;
7143
7302
  /** A failed publish is tried again this much later (not before the hourly one otherwise). */
7144
- var RETRY_MS$1 = 3e5;
7303
+ var RETRY_MS$2 = 3e5;
7145
7304
  /** Relay payload header: signature and sequence number, before the DNS packet. */
7146
7305
  var HEADER = 72;
7147
7306
  var ProfileDid = class {
@@ -7179,7 +7338,7 @@ var ProfileDid = class {
7179
7338
  }
7180
7339
  start() {
7181
7340
  this.running = true;
7182
- this.schedule(FIRST_PUBLISH_MS);
7341
+ this.schedule(FIRST_PUBLISH_MS$1);
7183
7342
  }
7184
7343
  stop() {
7185
7344
  this.running = false;
@@ -7265,7 +7424,7 @@ var ProfileDid = class {
7265
7424
  this.timer = setTimeout(() => {
7266
7425
  this.timer = null;
7267
7426
  this.publishNow().finally(() => {
7268
- if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$1 : DID_REPUBLISH_MS);
7427
+ if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$2 : DID_REPUBLISH_MS);
7269
7428
  });
7270
7429
  }, ms);
7271
7430
  }
@@ -7346,6 +7505,558 @@ var ProfileDid = class {
7346
7505
  this.stored = next;
7347
7506
  }
7348
7507
  };
7508
+ //#endregion
7509
+ //#region ../browser/src/engine/contactLinks.ts
7510
+ var CONTACT_LINKS_KEY = "contactLinks";
7511
+ /** Mainline keeps a mutable item about two hours: the link record goes out again every hour. */
7512
+ var LINK_REPUBLISH_MS = 36e5;
7513
+ var OFF_FOR_MS = 2592e6;
7514
+ var FIRST_PUBLISH_MS = 15e3;
7515
+ var RETRY_MS$1 = 3e5;
7516
+ /** How often the knock slots are read: every five minutes, and every 15 s for ten minutes after the link or Requests was shown. */
7517
+ var POLL_MS = 3e5;
7518
+ var POLL_SHOWN_MS = 15e3;
7519
+ var SHOWN_FOR_MS = 6e5;
7520
+ var ContactLinks = class {
7521
+ host;
7522
+ stored = { retired: [] };
7523
+ error;
7524
+ timer = null;
7525
+ running = false;
7526
+ inFlight = null;
7527
+ again = false;
7528
+ writes = Promise.resolve();
7529
+ pollTimer = null;
7530
+ shownAt = -Infinity;
7531
+ /** Making, turning off, taking and settling requests run one at a time: each reads and writes the whole record. */
7532
+ queue = Promise.resolve();
7533
+ constructor(host) {
7534
+ this.host = host;
7535
+ }
7536
+ async load() {
7537
+ const found = await wrap((await store(STORES$1.settings, "readonly")).get(CONTACT_LINKS_KEY));
7538
+ this.stored = found ?? { retired: [] };
7539
+ }
7540
+ start() {
7541
+ this.running = true;
7542
+ this.schedule(FIRST_PUBLISH_MS);
7543
+ this.schedulePoll(FIRST_PUBLISH_MS);
7544
+ }
7545
+ stop() {
7546
+ this.running = false;
7547
+ if (this.timer) clearTimeout(this.timer);
7548
+ this.timer = null;
7549
+ if (this.pollTimer) clearTimeout(this.pollTimer);
7550
+ this.pollTimer = null;
7551
+ }
7552
+ /**
7553
+ * The link was shown or copied, or Requests opened: people open a link in the minutes after it is shared, and the
7554
+ * owner looking at Requests wants what came. Look now, and more often for ten minutes.
7555
+ */
7556
+ shown() {
7557
+ this.shownAt = Date.now();
7558
+ this.schedulePoll(0);
7559
+ }
7560
+ /** Whether a link key is one of this profile's, current or turned off. */
7561
+ owns(linkKeyZ32) {
7562
+ return this.stored.current?.linkKeyZ32 === linkKeyZ32 || this.stored.retired.some((link) => link.linkKeyZ32 === linkKeyZ32);
7563
+ }
7564
+ /** The requests waiting, newest first. */
7565
+ requests() {
7566
+ return [...this.stored.current?.requests ?? []].reverse().map((r) => ({
7567
+ id: r.key,
7568
+ name: r.name,
7569
+ text: r.text,
7570
+ receivedAt: r.receivedAt,
7571
+ expires: r.expires
7572
+ }));
7573
+ }
7574
+ /**
7575
+ * Accept: the owner joins the invite the request carries, and the chat starts as any chat does. The request leaves
7576
+ * Requests and its asker key is never read again on this link.
7577
+ */
7578
+ accept(id) {
7579
+ return this.serial(async () => {
7580
+ const request = this.stored.current?.requests?.find((r) => r.key === id);
7581
+ if (!request) throw new Error("This request is no longer waiting");
7582
+ if (request.expires <= Date.now()) throw new Error("This request expired");
7583
+ const linkId = await this.host.accept(request);
7584
+ await this.settle(id);
7585
+ return { linkId };
7586
+ });
7587
+ }
7588
+ /** Decline: nothing is said back. The request's name and text are deleted, and its asker key is never read again. */
7589
+ decline(id) {
7590
+ return this.serial(() => this.settle(id));
7591
+ }
7592
+ /** Reads the knock slots once and takes what is new into Requests. */
7593
+ poll() {
7594
+ return this.serial(() => this.take());
7595
+ }
7596
+ /** Undefined while the profile has no link. */
7597
+ view() {
7598
+ const current = this.stored.current;
7599
+ if (!current) return void 0;
7600
+ const code = encodeContactLink(current.linkKeyZ32);
7601
+ return {
7602
+ code,
7603
+ link: inviteLink(code),
7604
+ createdAt: current.createdAt,
7605
+ state: current.inbox.state === "busy" ? "busy" : "open",
7606
+ waiting: current.inbox.waiting.length,
7607
+ missed: current.inbox.missed.length,
7608
+ ...current.published ? { publishedAt: current.published } : {},
7609
+ ...this.error ? { error: this.error } : {}
7610
+ };
7611
+ }
7612
+ /** Makes the profile's link, turning the one before off: one link per profile at a time. Requests keeps what waits. */
7613
+ create() {
7614
+ return this.serial(() => this.make());
7615
+ }
7616
+ async make() {
7617
+ const made = createContactLink();
7618
+ const deviceKey = newDeviceKey();
7619
+ const now = Date.now();
7620
+ const retired = this.retire(now);
7621
+ const waiting = this.stored.current?.inbox.waiting.filter((w) => w.expires > now) ?? [];
7622
+ const requests = this.stored.current?.requests?.filter((r) => waiting.some((w) => w.key === r.key)) ?? [];
7623
+ const inbox = {
7624
+ ...newInbox(now),
7625
+ waiting,
7626
+ state: waiting.length >= 32 ? "busy" : "open"
7627
+ };
7628
+ await this.save({
7629
+ current: {
7630
+ seed: {
7631
+ sealed: await sealSeed(made.seedB64, deviceKey),
7632
+ deviceKey
7633
+ },
7634
+ linkKeyZ32: made.linkKeyZ32,
7635
+ createdAt: now,
7636
+ inbox,
7637
+ ...requests.length ? { requests } : {}
7638
+ },
7639
+ retired
7640
+ });
7641
+ this.host.emit();
7642
+ await this.publishNow();
7643
+ return this.view();
7644
+ }
7645
+ /** Turns the link off, for good: requesters who open it are told so. */
7646
+ turnOff() {
7647
+ return this.serial(() => this.off());
7648
+ }
7649
+ async off() {
7650
+ if (!this.stored.current) return;
7651
+ await this.save({ retired: this.retire(Date.now()) });
7652
+ this.host.emit();
7653
+ await this.publishNow();
7654
+ }
7655
+ /** "Clear all data": the link says `off` once, now, and nothing is kept. */
7656
+ async withdraw() {
7657
+ this.stop();
7658
+ const links = [...this.stored.current ? [this.stored.current] : [], ...this.stored.retired];
7659
+ await Promise.allSettled(links.map(async (link) => this.host.publish(await this.identity(link), linkRecords(inboxRecord(turnOff(newInbox(Date.now())), Date.now())))));
7660
+ }
7661
+ /** Publishes what is due now: the current link's record, and `off` for each retired link not said today. */
7662
+ async publishNow() {
7663
+ if (this.inFlight) {
7664
+ this.again = true;
7665
+ return this.inFlight;
7666
+ }
7667
+ this.inFlight = this.publish().finally(() => {
7668
+ this.inFlight = null;
7669
+ });
7670
+ await this.inFlight;
7671
+ if (this.again) {
7672
+ this.again = false;
7673
+ await this.publishNow();
7674
+ }
7675
+ }
7676
+ serial(work) {
7677
+ const run = this.queue.then(work, work);
7678
+ this.queue = run.catch(() => {});
7679
+ return run;
7680
+ }
7681
+ schedulePoll(ms) {
7682
+ if (!this.running) return;
7683
+ if (this.pollTimer) clearTimeout(this.pollTimer);
7684
+ this.pollTimer = setTimeout(() => {
7685
+ this.pollTimer = null;
7686
+ this.poll().catch(() => {}).finally(() => {
7687
+ if (!this.running || this.pollTimer) return;
7688
+ const shown = Date.now() - this.shownAt < SHOWN_FOR_MS;
7689
+ this.schedulePoll(shown ? POLL_SHOWN_MS : POLL_MS);
7690
+ });
7691
+ }, ms);
7692
+ }
7693
+ async take() {
7694
+ const current = this.stored.current;
7695
+ if (!current || !this.host.online()) return;
7696
+ const now = Date.now();
7697
+ const linkKey = current.linkKeyZ32;
7698
+ const slots = await Promise.all(Array.from({ length: 4 }, (_, i) => this.host.resolve(knockSlotIdentity(linkKey, i).pubKeyZ32).catch(() => null)));
7699
+ let inbox = current.inbox;
7700
+ for (const key of endedRequests(inbox, now)) inbox = settleRequest(inbox, key, "ended");
7701
+ const plan = planKnocks(linkKey, inbox, slots.flatMap((records) => records ? readKnockSlot(linkKey, records) : []), now);
7702
+ inbox = plan.inbox;
7703
+ const taken = [];
7704
+ if (plan.read.length) {
7705
+ const { seedB64 } = await this.identity(current);
7706
+ for (const key of plan.read) {
7707
+ const records = await this.host.resolve(key).catch(() => null);
7708
+ const request = records ? openRequest(seedB64, key, records, now) : null;
7709
+ if (!request || this.host.ownInvite(request.invite)) continue;
7710
+ inbox = takeRequest(linkKey, inbox, key, request.expires, now);
7711
+ taken.push({
7712
+ ...request,
7713
+ key,
7714
+ receivedAt: now
7715
+ });
7716
+ }
7717
+ }
7718
+ const latest = this.stored.current;
7719
+ if (!latest || latest.linkKeyZ32 !== linkKey) return;
7720
+ const requests = [...current.requests ?? [], ...taken].filter((r) => inbox.waiting.some((w) => w.key === r.key));
7721
+ const changed = plan.publish || taken.length > 0 || inbox.state !== current.inbox.state || inbox.bits !== current.inbox.bits;
7722
+ await this.save({
7723
+ ...this.stored,
7724
+ current: {
7725
+ ...latest,
7726
+ inbox,
7727
+ requests
7728
+ }
7729
+ });
7730
+ this.host.emit();
7731
+ if (changed) await this.publishNow();
7732
+ }
7733
+ async settle(key) {
7734
+ const current = this.stored.current;
7735
+ if (!current?.requests?.some((r) => r.key === key)) return;
7736
+ const inbox = clearMissed(settleRequest(current.inbox, key));
7737
+ await this.save({
7738
+ ...this.stored,
7739
+ current: {
7740
+ ...current,
7741
+ inbox,
7742
+ requests: current.requests.filter((r) => r.key !== key)
7743
+ }
7744
+ });
7745
+ this.host.emit();
7746
+ if (inbox.state !== current.inbox.state) await this.publishNow();
7747
+ }
7748
+ retire(now) {
7749
+ const kept = this.stored.retired.filter((link) => now - link.offAt < OFF_FOR_MS);
7750
+ const current = this.stored.current;
7751
+ return current ? [...kept, {
7752
+ seed: current.seed,
7753
+ linkKeyZ32: current.linkKeyZ32,
7754
+ createdAt: current.createdAt,
7755
+ offAt: now
7756
+ }] : kept;
7757
+ }
7758
+ schedule(ms) {
7759
+ if (!this.running) return;
7760
+ if (this.timer) clearTimeout(this.timer);
7761
+ this.timer = setTimeout(() => {
7762
+ this.timer = null;
7763
+ this.publishNow().finally(() => {
7764
+ if (this.running && !this.timer) this.schedule(this.error ? RETRY_MS$1 : LINK_REPUBLISH_MS);
7765
+ });
7766
+ }, ms);
7767
+ }
7768
+ async publish() {
7769
+ if (!this.host.online()) return;
7770
+ const now = Date.now();
7771
+ const { current, retired } = this.stored;
7772
+ let published = false;
7773
+ const said = /* @__PURE__ */ new Set();
7774
+ this.error = void 0;
7775
+ if (current) try {
7776
+ await this.host.publish(await this.identity(current), linkRecords(inboxRecord(current.inbox, now)));
7777
+ published = true;
7778
+ } catch (error) {
7779
+ this.error = error instanceof Error ? error.message : String(error);
7780
+ }
7781
+ for (const link of retired) {
7782
+ if (now - link.offAt >= 2592e6 || link.said !== void 0 && now - link.said < 864e5) continue;
7783
+ try {
7784
+ await this.host.publish(await this.identity(link), linkRecords(inboxRecord(turnOff(newInbox(now)), now)));
7785
+ said.add(link.linkKeyZ32);
7786
+ } catch {}
7787
+ }
7788
+ const latest = this.stored;
7789
+ await this.save({
7790
+ ...latest.current ? { current: published && latest.current.linkKeyZ32 === current?.linkKeyZ32 ? {
7791
+ ...latest.current,
7792
+ published: now
7793
+ } : latest.current } : {},
7794
+ retired: latest.retired.filter((link) => now - link.offAt < OFF_FOR_MS).map((link) => said.has(link.linkKeyZ32) ? {
7795
+ ...link,
7796
+ said: now
7797
+ } : link)
7798
+ });
7799
+ this.host.emit();
7800
+ }
7801
+ async identity(link) {
7802
+ return identityFromSeedB64(await unsealSeed(link.seed.sealed, link.seed.deviceKey));
7803
+ }
7804
+ /**
7805
+ * The record is the one in memory from now on, and the writes go to the database in the order they were made: a
7806
+ * publish that ends while a request is being taken can never put back the record from before it.
7807
+ */
7808
+ save(next) {
7809
+ this.stored = next;
7810
+ const write = this.writes.then(async () => {
7811
+ await wrap((await store(STORES$1.settings, "readwrite")).put(next, CONTACT_LINKS_KEY));
7812
+ });
7813
+ this.writes = write.catch(() => {});
7814
+ return write;
7815
+ }
7816
+ };
7817
+ //#endregion
7818
+ //#region ../browser/src/engine/contactAsks.ts
7819
+ var CONTACT_ASKS_KEY = "contactAsks";
7820
+ /** How often a request not yet seen is looked after: every 30 s for ten minutes, then every ten minutes. */
7821
+ var ASK_FAST_MS = 3e4;
7822
+ var ASK_SLOW_MS = 6e5;
7823
+ /** After a knock, its slot is read again this much later, twice at most: another requester may have written at once. */
7824
+ var KNOCK_CHECK_MS = 1e3;
7825
+ var SLACK_MS = 5e3;
7826
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7827
+ var ContactAsks = class {
7828
+ host;
7829
+ asks = [];
7830
+ timer = null;
7831
+ running = false;
7832
+ queue = Promise.resolve();
7833
+ constructor(host) {
7834
+ this.host = host;
7835
+ }
7836
+ async load() {
7837
+ this.asks = await wrap((await store(STORES$1.settings, "readonly")).get("contactAsks")) ?? [];
7838
+ }
7839
+ start() {
7840
+ this.running = true;
7841
+ this.schedule(ASK_FAST_MS);
7842
+ }
7843
+ stop() {
7844
+ this.running = false;
7845
+ if (this.timer) clearTimeout(this.timer);
7846
+ this.timer = null;
7847
+ }
7848
+ views() {
7849
+ return this.asks.filter((a) => a.chatId && a.state).map((a) => ({
7850
+ chatId: a.chatId,
7851
+ state: a.state,
7852
+ expires: a.expires
7853
+ }));
7854
+ }
7855
+ /**
7856
+ * Sends a request through a contact link. Refused, with the words the person reads, for a code that is not a contact
7857
+ * link, the profile's own link, a link turned off or busy, a request already waiting there, or a name or text that
7858
+ * breaks a bound. Nothing is made before every check passed.
7859
+ */
7860
+ ask(params) {
7861
+ return this.serial(() => this.send(params));
7862
+ }
7863
+ /**
7864
+ * The chat a request made was deleted: the request is taken back. Nothing more goes out for it, its record under the
7865
+ * asker key is replaced by an empty one so an owner who has not read it yet finds nothing, and asking again through
7866
+ * the link is open. The asker key stays, one per profile and link. An owner who already took it keeps it until it ends.
7867
+ */
7868
+ forgetChat(chatId) {
7869
+ return this.serial(async () => {
7870
+ const ask = this.asks.find((a) => a.chatId === chatId);
7871
+ if (!ask) return;
7872
+ const out = ask.state === "sending" || ask.state === "knocking" || ask.state === "seen";
7873
+ await this.put({
7874
+ link: ask.link,
7875
+ seed: ask.seed,
7876
+ key: ask.key
7877
+ });
7878
+ this.host.emit();
7879
+ if (!out || !this.host.online()) return;
7880
+ try {
7881
+ await this.host.publish(await this.identity(ask), []);
7882
+ } catch {}
7883
+ });
7884
+ }
7885
+ /** Looks after every request not yet seen once (the timer does this at its pace). */
7886
+ tick() {
7887
+ return this.serial(() => this.look());
7888
+ }
7889
+ async send({ code, name, text, label }) {
7890
+ const reading = readContactLink(code);
7891
+ if (!reading.ok) throw new Error(reading.reason === "chat" ? "This is a chat invite: open it in Join." : "This is not a Ghostly contact link.");
7892
+ const link = reading.linkKeyZ32;
7893
+ if (this.host.ownLink(link)) throw new Error("This is your contact link.");
7894
+ const now = Date.now();
7895
+ const known = this.asks.find((a) => a.link === link);
7896
+ if (known?.state && known.state !== "ended" && known.state !== "off" && (known.expires ?? 0) > now) throw new Error("You already sent a request through this link.");
7897
+ const record = readLinkRecord(await this.host.resolve(link) ?? []);
7898
+ if (record?.state === "off") throw new Error("This contact link was turned off.");
7899
+ if (record?.state === "busy") throw new Error("They are not taking requests right now. Try again later.");
7900
+ const asker = known ? await this.identity(known) : createIdentity();
7901
+ const request = (invite) => ({
7902
+ invite,
7903
+ name: name.trim(),
7904
+ text: text.trim(),
7905
+ issued: now,
7906
+ expires: now + REQUEST_LIFETIME_MS
7907
+ });
7908
+ try {
7909
+ requestRecords(asker.seedB64, link, request(createChatInvite().inviteCode));
7910
+ } catch (error) {
7911
+ if (!(error instanceof ContactRequestError)) throw error;
7912
+ throw new Error(error.problem === "name" ? "Write a name of up to 48 characters." : error.problem === "too-long" ? "Shorten the message." : "Write a message of up to 200 bytes.");
7913
+ }
7914
+ const chat = await this.host.createChat({
7915
+ ...label?.trim() ? { label: label.trim() } : {},
7916
+ text: text.trim()
7917
+ });
7918
+ const sent = request(chat.inviteCode);
7919
+ const ask = {
7920
+ link,
7921
+ key: asker.pubKeyZ32,
7922
+ seed: known?.seed ?? await this.seal(asker),
7923
+ chatId: chat.linkId,
7924
+ invite: chat.inviteCode,
7925
+ name: sent.name,
7926
+ text: sent.text,
7927
+ issued: now,
7928
+ expires: sent.expires,
7929
+ state: "sending"
7930
+ };
7931
+ await this.put(ask);
7932
+ this.tick().catch(() => {});
7933
+ this.schedule(ASK_FAST_MS);
7934
+ return { chatId: chat.linkId };
7935
+ }
7936
+ /** Publishes the request when due and knocks when its knock is gone. Never throws: the next look tries again. */
7937
+ async goOut(ask, asker, publish) {
7938
+ const now = Date.now();
7939
+ try {
7940
+ if (publish) {
7941
+ await this.host.publish(asker, requestRecords(asker.seedB64, ask.link, {
7942
+ invite: ask.invite,
7943
+ name: ask.name,
7944
+ text: ask.text,
7945
+ issued: ask.issued,
7946
+ expires: ask.expires
7947
+ }));
7948
+ ask = {
7949
+ ...ask,
7950
+ publishedAt: now
7951
+ };
7952
+ }
7953
+ const slot = knockSlotIdentity(ask.link, knockSlotOf(ask.link, ask.key));
7954
+ for (let attempt = 0; attempt < 3; attempt++) {
7955
+ const knocks = readKnockSlot(ask.link, await this.host.resolve(slot.pubKeyZ32) ?? []);
7956
+ const stamp = ask.stamp;
7957
+ if (knocks.some((k) => k.key === ask.key && k.day === stamp.day && k.stamp === stamp.stamp)) break;
7958
+ if (attempt === 2) break;
7959
+ await this.host.publish(slot, knockSlotRecords(ask.link, mergeContactKnocks(knocks, {
7960
+ key: ask.key,
7961
+ day: stamp.day,
7962
+ stamp: stamp.stamp
7963
+ }, now)));
7964
+ await sleep(KNOCK_CHECK_MS);
7965
+ }
7966
+ ask = {
7967
+ ...ask,
7968
+ state: ask.state === "sending" ? "knocking" : ask.state,
7969
+ lookedAt: now
7970
+ };
7971
+ } catch {
7972
+ ask = {
7973
+ ...ask,
7974
+ lookedAt: now
7975
+ };
7976
+ }
7977
+ await this.put(ask);
7978
+ this.host.emit();
7979
+ }
7980
+ async look() {
7981
+ if (!this.host.online()) return;
7982
+ const now = Date.now();
7983
+ for (const ask of this.asks) {
7984
+ if (ask.state !== "sending" && ask.state !== "knocking" && ask.state !== "seen") continue;
7985
+ if (ask.expires <= now) {
7986
+ await this.put({
7987
+ ...ask,
7988
+ state: "ended"
7989
+ });
7990
+ this.host.emit();
7991
+ continue;
7992
+ }
7993
+ if (ask.state === "seen") continue;
7994
+ const pace = now - ask.issued < 6e5 ? ASK_FAST_MS : ASK_SLOW_MS;
7995
+ if (ask.lookedAt !== void 0 && now - ask.lookedAt < pace - SLACK_MS) continue;
7996
+ const record = readLinkRecord(await this.host.resolve(ask.link).catch(() => null) ?? []);
7997
+ if (record?.state === "off") {
7998
+ await this.put({
7999
+ ...ask,
8000
+ state: "off"
8001
+ });
8002
+ this.host.emit();
8003
+ continue;
8004
+ }
8005
+ if (record?.seen.includes(seenEntry(ask.link, ask.key))) {
8006
+ await this.put({
8007
+ ...ask,
8008
+ state: "seen"
8009
+ });
8010
+ this.host.emit();
8011
+ continue;
8012
+ }
8013
+ let next = ask;
8014
+ const day = contactDay(now);
8015
+ const bits = Math.max(record?.bits ?? 18, 18);
8016
+ if (!next.stamp || next.stamp.day < day - 1 || next.stamp.bits < bits) next = {
8017
+ ...next,
8018
+ stamp: {
8019
+ day,
8020
+ bits,
8021
+ stamp: await makeStampPaced(ask.link, ask.key, day, bits)
8022
+ }
8023
+ };
8024
+ await this.goOut(next, await this.identity(next), next.publishedAt === void 0 || now - next.publishedAt >= 36e5);
8025
+ }
8026
+ }
8027
+ schedule(ms) {
8028
+ if (!this.running) return;
8029
+ if (this.timer) clearTimeout(this.timer);
8030
+ this.timer = setTimeout(() => {
8031
+ this.timer = null;
8032
+ this.tick().catch(() => {}).finally(() => {
8033
+ if (!this.running || this.timer) return;
8034
+ if (this.asks.some((a) => a.state === "sending" || a.state === "knocking")) this.schedule(ASK_FAST_MS);
8035
+ else if (this.asks.some((a) => a.state === "seen")) this.schedule(ASK_SLOW_MS);
8036
+ });
8037
+ }, ms);
8038
+ }
8039
+ serial(work) {
8040
+ const run = this.queue.then(work, work);
8041
+ this.queue = run.catch(() => {});
8042
+ return run;
8043
+ }
8044
+ async seal(identity) {
8045
+ const deviceKey = newDeviceKey();
8046
+ return {
8047
+ sealed: await sealSeed(identity.seedB64, deviceKey),
8048
+ deviceKey
8049
+ };
8050
+ }
8051
+ async identity(ask) {
8052
+ return identityFromSeedB64(await unsealSeed(ask.seed.sealed, ask.seed.deviceKey));
8053
+ }
8054
+ async put(ask) {
8055
+ const next = [...this.asks.filter((a) => a.link !== ask.link), ask];
8056
+ await wrap((await store(STORES$1.settings, "readwrite")).put(next, CONTACT_ASKS_KEY));
8057
+ this.asks = next;
8058
+ }
8059
+ };
7349
8060
  function profileName(value) {
7350
8061
  if (typeof value !== "string" || value.length > 256) return;
7351
8062
  return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 64) || void 0;
@@ -9883,6 +10594,17 @@ function forwardKind(message) {
9883
10594
  if (message.file) return "file";
9884
10595
  return message.text.trim() ? "text" : { refused: "This message cannot be forwarded" };
9885
10596
  }
10597
+ /**
10598
+ * Why a chat did not get a forward, as its result says it: never "" (a result with an error is a failure, whatever it
10599
+ * says). A copy that found no room says so in the words the app knows however the storage put it: IndexedDB's
10600
+ * QuotaExceededError (an empty message in Chromium), the OPFS worker's (named so), Desktop's io error (the system's English).
10601
+ */
10602
+ function forwardError(error) {
10603
+ const message = (error instanceof Error ? error.message : String(error)).trim();
10604
+ if (/^No space left on device\b/.test(message)) return `Not enough space on this device (${message})`;
10605
+ const name = error?.name;
10606
+ return errorLine(error).trim() || (typeof name === "string" && name ? name : "Error");
10607
+ }
9886
10608
  /** How many bytes of a stored file are actually here: all of them once it arrived. */
9887
10609
  async function bytesHere(file) {
9888
10610
  if (file.blob) return file.blob.size;
@@ -10164,14 +10886,14 @@ var DEFAULT_APP_STORES = [{
10164
10886
  * entry is added with the pin of its bundle in apps/ui/src/assets/apps/built-in.json, which the build reads from the
10165
10887
  * app's own repository (apps/ui/scripts/built-in-apps.mjs); no bundle is kept in this repository.
10166
10888
  *
10167
- * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.0 (`sequence` 4), at the
10889
+ * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.1 (`sequence` 5), at the
10168
10890
  * commit of its repository that the official store lists.
10169
10891
  */
10170
10892
  var BUILT_IN_APPS = [{
10171
10893
  publisher: "odcgw6wjw8dynqop84r47jbjcdqossbgrfiejd367e14hgxmjcho",
10172
10894
  name: "chess",
10173
- digest: "EEnFmP0grTkTe_9qgapQsEEZzWB9FcpoimWkHnfecVs",
10174
- url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@c665ab194478145fd9b9a4777e6c0d1893969649/app.ghostlyapp",
10895
+ digest: "PnrjBRayGNbhfCkjPXmxLpjKYe2_m9Q65_hUPP8BHOc",
10896
+ url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@0cbf1acc8fd7a03848f4e2a68cbec8854767ceaa/app.ghostlyapp",
10175
10897
  title: "Chess",
10176
10898
  tagline: "Play chess with a contact, live in your chat"
10177
10899
  }];
@@ -19822,6 +20544,11 @@ var Outbox = class {
19822
20544
  get sameLink() {
19823
20545
  return !this.stopped && this.closed === this.began;
19824
20546
  }
20547
+ /** A check is scheduled for something queued or waiting: one that went out keeps its `waiting` entry (its backoff step), not its timer. */
20548
+ get scheduled() {
20549
+ for (const wait of this.waiting.values()) if (wait.timer) return true;
20550
+ return false;
20551
+ }
19825
20552
  /** One message as stored now: by its id when the store can, else picked out of the chat's history. */
19826
20553
  async one(id) {
19827
20554
  return this.store.get ? this.store.get(id) : (await this.store.read()).find((m) => m.id === id);
@@ -19872,12 +20599,6 @@ var Outbox = class {
19872
20599
  const unconfirmed = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sent" && this.receipts.has(m.id);
19873
20600
  return messages.filter((m) => !this.busy.has(m.id) && (toGo(m) || this.file(m) || unconfirmed(m) && (opened !== void 0 && (this.sentUnder.get(m.id) ?? 0) < opened || !!this.resender.via && this.resender.via(m) !== m.via && this.resender.ready(m)))).sort(oldestFirst);
19874
20601
  }
19875
- /**
19876
- * Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
19877
- * handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
19878
- * a moment before it carries text, and a look that began in that moment sent the later messages and left the first
19879
- * ones behind.
19880
- */
19881
20602
  /** Whether this chat's waiting files go through this outbox (`Resender.sendFile`). */
19882
20603
  get sendsFiles() {
19883
20604
  return !!this.resender.sendFile;
@@ -19892,11 +20613,20 @@ var Outbox = class {
19892
20613
  return false;
19893
20614
  }
19894
20615
  if (passed) await this.catchUp(passed);
19895
- if (!this.sameLink) return true;
20616
+ if (!this.sameLink) {
20617
+ if (!this.stopped && !this.waiting.get(message.id)?.timer) this.schedule(message.id);
20618
+ return true;
20619
+ }
19896
20620
  await this.resender.sendFile(message).catch(() => {});
19897
20621
  this.forget(message.id);
19898
20622
  return true;
19899
20623
  }
20624
+ /**
20625
+ * Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
20626
+ * handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
20627
+ * a moment before it carries text, and a look that began in that moment sent the later messages and left the first
20628
+ * ones behind.
20629
+ */
19900
20630
  async inOrder(messages) {
19901
20631
  const passed = [];
19902
20632
  for (const message of messages) {
@@ -19991,15 +20721,16 @@ var Outbox = class {
19991
20721
  await this.store.update(id, "delivered");
19992
20722
  this.clear(id);
19993
20723
  this.forget(id);
19994
- if (this.waiting.size) this.flush().catch(() => {});
20724
+ if (this.scheduled) this.flush().catch(() => {});
19995
20725
  }
19996
20726
  /**
19997
20727
  * The chat can carry text again (its link came back, or the DHT path freed up): every queued message goes now,
19998
20728
  * oldest first. `reopened`: a new link, so what still awaits a receipt from the old one, or from the DHT, goes on it too
19999
- * (a receipt on the link also ends the DHT's own retries).
20729
+ * (a receipt on the link also ends the DHT's own retries). Bare, it looks only when a check is scheduled: a receipt,
20730
+ * and every read of the contact's DHT mailbox, call it.
20000
20731
  */
20001
20732
  flush({ reopened = false } = {}) {
20002
- if (this.stopped || !reopened && !this.waiting.size) return Promise.resolve();
20733
+ if (this.stopped || !reopened && !this.scheduled) return Promise.resolve();
20003
20734
  const opened = reopened ? ++this.opened : void 0;
20004
20735
  return this.turn(async () => {
20005
20736
  if (this.stopped) return;
@@ -20007,15 +20738,47 @@ var Outbox = class {
20007
20738
  await this.inOrder(this.due(await this.store.read(), opened));
20008
20739
  });
20009
20740
  }
20010
- /** A queued message's check came due: the ones written before it that have not gone are looked at first. */
20741
+ /**
20742
+ * A queued message's check came due: the ones written before it that have not gone are looked at first. The checks
20743
+ * of n texts written to an away contact come due a few milliseconds apart, and each read the chat and looked at
20744
+ * every older text again: n²/2 reads at each backoff step. The first look now takes up every check due by the next
20745
+ * few seconds too (`backoffMs[0]`), and what was written before them, and gives each its next one: they come due
20746
+ * together from then on, and the others find theirs taken.
20747
+ */
20011
20748
  async check(id) {
20012
20749
  if (this.stopped) return;
20750
+ if (this.waiting.get(id)?.timer || this.receipts.has(id)) return;
20013
20751
  const messages = await this.store.read(), message = messages.find((m) => m.id === id);
20014
20752
  if (!message || !(toGo(message) || this.file(message))) {
20015
20753
  await this.attempt(id);
20016
20754
  return;
20017
20755
  }
20018
- await this.inOrder(this.due(messages.filter((m) => m.timestamp <= message.timestamp)));
20756
+ const soon = this.now() + this.policy.backoffMs[0], taken = [];
20757
+ let until = message.timestamp;
20758
+ for (const m of messages) {
20759
+ const wait = this.waiting.get(m.id);
20760
+ if (m.id === id || !wait || this.busy.has(m.id) || !(toGo(m) || this.file(m)) || wait.timer && wait.at > soon) continue;
20761
+ if (wait.timer) {
20762
+ clearTimeout(wait.timer);
20763
+ wait.timer = void 0;
20764
+ wait.step++;
20765
+ }
20766
+ taken.push(m.id);
20767
+ if (m.timestamp > until) until = m.timestamp;
20768
+ }
20769
+ await this.inOrder(this.due(messages.filter((m) => m.timestamp <= until)));
20770
+ for (const other of taken) {
20771
+ const wait = this.waiting.get(other);
20772
+ if (!this.stopped && wait && !wait.timer && !this.busy.has(other) && !this.receipts.has(other)) this.schedule(other);
20773
+ }
20774
+ }
20775
+ /**
20776
+ * As read by the look in progress, it still waits: the chat cannot carry it, and it is not due to fail or to be
20777
+ * handed to store-and-forward. Nothing acts on it, so it is not read again.
20778
+ */
20779
+ waitsOn(message) {
20780
+ const wait = this.waiting.get(message.id);
20781
+ return !!wait && (message.delivery === "queued" || message.delivery === "waiting") && message.via !== "hold" && (message.resendUntil ?? Infinity) > this.now() && !this.resender.ready(message) && !(this.resender.divert && this.now() - wait.since >= this.policy.holdAfterMs);
20019
20782
  }
20020
20783
  /**
20021
20784
  * A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry
@@ -20023,7 +20786,7 @@ var Outbox = class {
20023
20786
  */
20024
20787
  async again(message, passed) {
20025
20788
  if (message.sender !== "me" || message.via === "hold") return true;
20026
- if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed);
20789
+ if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed, message);
20027
20790
  if (message.delivery !== "sent" || !this.receipts.has(message.id)) return true;
20028
20791
  this.clear(message.id);
20029
20792
  if (this.resender.ready(message)) {
@@ -20067,10 +20830,15 @@ var Outbox = class {
20067
20830
  /**
20068
20831
  * Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more.
20069
20832
  * False when the chat could not carry it and it still waits. `passed`: older messages the chat could not carry a
20070
- * moment ago, which go first if it can now.
20833
+ * moment ago, which go first if it can now. `read`: its row as the look in progress read it; while it still waits
20834
+ * (`waitsOn`) it is not read again, else a look at n waiting texts read each of them once more.
20071
20835
  */
20072
- async attempt(id, passed) {
20836
+ async attempt(id, passed, read) {
20073
20837
  if (this.stopped || this.busy.has(id)) return true;
20838
+ if (read && this.waitsOn(read)) {
20839
+ if (!this.waiting.get(id)?.timer) this.schedule(id);
20840
+ return false;
20841
+ }
20074
20842
  const message = await this.one(id);
20075
20843
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
20076
20844
  this.forget(id);
@@ -20122,10 +20890,12 @@ var Outbox = class {
20122
20890
  schedule(id) {
20123
20891
  const wait = this.waiting.get(id) ?? {
20124
20892
  step: 0,
20125
- since: this.now()
20893
+ since: this.now(),
20894
+ at: 0
20126
20895
  };
20127
20896
  if (wait.timer) clearTimeout(wait.timer);
20128
20897
  const delay = this.policy.backoffMs[Math.min(wait.step, this.policy.backoffMs.length - 1)];
20898
+ wait.at = this.now() + delay;
20129
20899
  wait.timer = setTimeout(() => {
20130
20900
  wait.timer = void 0;
20131
20901
  wait.step++;
@@ -20194,6 +20964,9 @@ var EditQueue = class {
20194
20964
  restoredAt = -Infinity;
20195
20965
  /** The rows known to have an edit on its way, each with when it was last told (`edited`), by `tick`. */
20196
20966
  known = /* @__PURE__ */ new Map();
20967
+ /** Of the known rows read: each one's wire id, and the DHT floor id of the edit it last sent (`dhtEditId`). A receipt finds its row by them. */
20968
+ wires = /* @__PURE__ */ new Map();
20969
+ dhtIds = /* @__PURE__ */ new Map();
20197
20970
  tick = 0;
20198
20971
  /** The chat's history was read for the rows with an edit on its way: `known` has them all, until the next session. */
20199
20972
  learnt = false;
@@ -20247,40 +21020,57 @@ var EditQueue = class {
20247
21020
  }
20248
21021
  /**
20249
21022
  * My rows with an edit on its way, by time. `all`: the chat's whole history, when it was read for them (no `row` to
20250
- * read one by, or nothing learnt yet in this session). Otherwise the known rows are read alone, and one that waits
20251
- * no more is forgotten, unless it was told again while it was read.
21023
+ * read them by, or nothing learnt yet in this session). Otherwise the known rows (`only`: those of them) are read
21024
+ * alone, in one read, and one that waits no more is forgotten, unless it was told again while it was read.
20252
21025
  */
20253
- async pending() {
21026
+ async pending(only) {
20254
21027
  const waits = (m) => m.sender === "me" && !!m.edit?.pending;
20255
- const row = this.deps.row;
20256
- if (!row || !this.learnt) {
21028
+ const read = this.deps.rows;
21029
+ if (!read || !this.learnt) {
20257
21030
  const all = await this.deps.read(), rows = all.filter(waits);
20258
- for (const m of rows) if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
21031
+ for (const m of rows) {
21032
+ if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
21033
+ if (m.wireId) this.wires.set(m.id, m.wireId);
21034
+ }
20259
21035
  this.learnt = true;
20260
21036
  return {
20261
21037
  rows,
20262
21038
  all
20263
21039
  };
20264
21040
  }
20265
- const asOf = this.tick, ids = [...this.known.keys()];
20266
- const read = await Promise.all(ids.map((id) => row(id)));
21041
+ const asOf = this.tick, ids = only ?? [...this.known.keys()];
20267
21042
  const rows = [];
20268
- read.forEach((m, i) => {
20269
- if (m && waits(m)) rows.push(m);
20270
- else if ((this.known.get(ids[i]) ?? 0) <= asOf) this.known.delete(ids[i]);
21043
+ (ids.length ? await read(ids) : []).forEach((m, i) => {
21044
+ if (m && waits(m)) {
21045
+ rows.push(m);
21046
+ if (m.wireId) this.wires.set(m.id, m.wireId);
21047
+ } else if ((this.known.get(ids[i]) ?? 0) <= asOf) {
21048
+ this.known.delete(ids[i]);
21049
+ this.wires.delete(ids[i]);
21050
+ this.dhtIds.delete(ids[i]);
21051
+ }
20271
21052
  });
20272
21053
  return { rows: rows.sort((a, b) => a.timestamp - b.timestamp) };
20273
21054
  }
21055
+ /**
21056
+ * The known rows a receipt may be for: the one `matches` finds among those read before, else those not read yet (none:
21057
+ * it names no edit on its way). Undefined: no rows to read them by, or nothing learnt yet, so all of them.
21058
+ */
21059
+ candidates(ids, matches) {
21060
+ if (!this.deps.rows || !this.learnt) return void 0;
21061
+ for (const [rowId, value] of ids) if (matches(value) && this.known.has(rowId)) return [rowId];
21062
+ return [...this.known.keys()].filter((rowId) => !ids.has(rowId));
21063
+ }
20274
21064
  /** The contact confirmed edit `seq` of my message with this wire id. */
20275
21065
  async received(wireId, seq) {
20276
- const row = (await this.pending()).rows.find((m) => m.wireId === wireId);
21066
+ const row = (await this.pending(this.candidates(this.wires, (id) => id === wireId))).rows.find((m) => m.wireId === wireId);
20277
21067
  if (!row?.edit?.pending || row.edit.seq > seq) return;
20278
21068
  this.sent.delete(row.id);
20279
21069
  await this.deps.settle(row.id, row.edit.seq);
20280
21070
  }
20281
21071
  /** The contact confirmed, on the DHT floor, the edit that went there under this id (`dhtEditId`). */
20282
21072
  async receivedOnDht(id) {
20283
- const row = (await this.pending()).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
21073
+ const row = (await this.pending(this.candidates(this.dhtIds, (sent) => sent === id))).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20284
21074
  if (!row) return;
20285
21075
  this.sent.delete(row.id);
20286
21076
  await this.deps.settle(row.id, row.edit.seq);
@@ -20291,7 +21081,7 @@ var EditQueue = class {
20291
21081
  this.timer = void 0;
20292
21082
  }
20293
21083
  async run() {
20294
- if (this.stopped) return;
21084
+ if (this.stopped || !this.deps.ready()) return;
20295
21085
  const now = this.now();
20296
21086
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20297
21087
  const { rows, all } = await this.pending();
@@ -20347,6 +21137,7 @@ var EditQueue = class {
20347
21137
  ...message.card && { sc: message.card }
20348
21138
  }, message)) continue;
20349
21139
  if (edit.restore) this.restoredAt = now;
21140
+ this.dhtIds.set(message.id, dhtEditId(message.wireId, edit.seq));
20350
21141
  this.sent.set(message.id, {
20351
21142
  seq: edit.seq,
20352
21143
  at: now,
@@ -20997,6 +21788,15 @@ var db = {
20997
21788
  return wrap((await store(STORES$1.messages, "readonly")).get([linkId, id]));
20998
21789
  },
20999
21790
  /**
21791
+ * These rows of a chat, in the order asked (undefined where none is), in one transaction: in the CLI's store
21792
+ * (fake-indexeddb) each transaction waiting at once is checked against all the others, so one per row costs the square.
21793
+ */
21794
+ async getMessagesById(linkId, ids) {
21795
+ if (!ids.length) return [];
21796
+ const messages = await store(STORES$1.messages, "readonly");
21797
+ return Promise.all(ids.map((id) => wrap(messages.get([linkId, id]))));
21798
+ },
21799
+ /**
21000
21800
  * The newest `limit` messages of a chat before `before`, oldest first, in `getMessages`' order (time, then id), and
21001
21801
  * whether older ones remain. Reads those rows only: a long chat's first page costs what a short one's does.
21002
21802
  * `before`: a message's place (the page ends just before it), or a time (`id` left out: the page ends before it).
@@ -21530,6 +22330,12 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
21530
22330
  * about a catch-up, whose answers end within the three minutes the app waits for the first.
21531
22331
  */
21532
22332
  var AUTHOR_ORDER_WINDOW_MS = 18e4;
22333
+ /**
22334
+ * While some of a member's messages are still on their way, how long that member's rows wait for the next one before
22335
+ * they are placed again anyway: longer than a catch-up takes to ask for its next 32 (`MESH_GAP_AGAIN_MS`, 10 s). Placed
22336
+ * at each page, the newest rows moved down again with every older page, up to 84 writes a row for 200 caught up.
22337
+ */
22338
+ var AUTHOR_ORDER_SETTLE_MS = 15e3;
21533
22339
  AUTHOR_ORDER_WINDOW_MS / 2;
21534
22340
  /** Whether `a` was written after `b` by the same member: a later epoch, or a higher number in the same one. */
21535
22341
  function after(a, b) {
@@ -21539,10 +22345,12 @@ function after(a, b) {
21539
22345
  * A member's messages in a private group in the order that member wrote them (WISP 400, requirement 10, and WISP 902
21540
22346
  * § Catch-up). A message is placed where it arrives. When one comes of a member whose later messages were placed here
21541
22347
  * moments ago (two catch-up answers raced: the author's own newest 32 and another member's hand-on of the older ones),
21542
- * those later ones are placed again below it, in the order they were written, and the lines that came after them before
21543
- * the older one (another member's, mine) go along below them, in their order: a reply to the newest stays below it. A
21544
- * row only ever moves down, so nobody else's lines change order. While messages of that member are still missing
21545
- * (`missing`), the rows wait for them, so each row moves about once in a whole catch-up.
22348
+ * it is stored just above the first of them where the history has room there (`above`): the author's newest stays
22349
+ * the last while the older pages come. Where it has none, those later ones are placed again below it, in the order
22350
+ * they were written, and the lines that came after them before the older one (another member's, mine) go along below
22351
+ * them, in their order: a reply to the newest stays below it. A row only ever moves down, so nobody else's lines change
22352
+ * order. While messages of that member are still missing (`missing`), the rows wait for them, so each row moves about
22353
+ * once in a whole catch-up.
21546
22354
  */
21547
22355
  var GroupAuthorOrder = class {
21548
22356
  host;
@@ -21550,6 +22358,7 @@ var GroupAuthorOrder = class {
21550
22358
  missing;
21551
22359
  /** Per group: the members' rows followed, in the order they are placed in the history. */
21552
22360
  rows = /* @__PURE__ */ new Map();
22361
+ came = 0;
21553
22362
  /** Per group: the members with a row placed above an older one of theirs, since when, and when their last row came. */
21554
22363
  due = /* @__PURE__ */ new Map();
21555
22364
  running = /* @__PURE__ */ new Set();
@@ -21558,14 +22367,37 @@ var GroupAuthorOrder = class {
21558
22367
  this.linkOf = linkOf;
21559
22368
  this.missing = missing;
21560
22369
  }
21561
- /** A member's message was stored in the group's history, new: `at` is when, on the engine's clock. */
21562
- placed(groupId, row) {
22370
+ /**
22371
+ * The row a member's message about to be stored goes just above: the first placed of those that member wrote after
22372
+ * it, when nobody else's came after that one (a hand-on of everyone's older messages brings them in the order they
22373
+ * were written: the message goes after those). None when nothing they wrote later was placed here, or another's
22374
+ * came since: it goes where it comes.
22375
+ */
22376
+ above(groupId, row) {
22377
+ const rows = this.rows.get(groupId) ?? [];
22378
+ if (rows.some((r) => r.id === row.id)) return void 0;
22379
+ const at = rows.findIndex((r) => r.sender === row.sender && after(r, row));
22380
+ return at >= 0 && rows.slice(at).every((r) => r.sender === row.sender) ? rows[at].id : void 0;
22381
+ }
22382
+ /**
22383
+ * A member's message was stored in the group's history, new: `at` is when, on the engine's clock. `above`: the row
22384
+ * it was stored just above, as `above` named it; else it went to the end.
22385
+ */
22386
+ placed(groupId, row, above) {
21563
22387
  if (!this.host.placeAgain) return;
21564
22388
  const rows = (this.rows.get(groupId) ?? []).filter((r) => r.at > row.at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21565
- rows.push(row);
22389
+ const at = above === void 0 ? -1 : rows.findIndex((r) => r.id === above);
22390
+ const followed = {
22391
+ ...row,
22392
+ n: ++this.came
22393
+ };
22394
+ if (at >= 0) rows.splice(at, 0, followed);
22395
+ else rows.push(followed);
21566
22396
  this.rows.set(groupId, rows);
21567
22397
  let due = this.due.get(groupId);
21568
- if (!due?.has(row.sender) && !rows.some((r) => r.sender === row.sender && after(r, row))) return;
22398
+ const first = rows.slice(0, at >= 0 ? at : -1).findIndex((r) => r.sender === row.sender && after(r, row));
22399
+ if (!due?.has(row.sender) && first < 0) return;
22400
+ if (first >= 0) for (const r of rows.slice(first)) r.at = Math.max(r.at, row.at);
21569
22401
  if (!due) this.due.set(groupId, due = /* @__PURE__ */ new Map());
21570
22402
  due.set(row.sender, {
21571
22403
  since: due.get(row.sender)?.since ?? row.at,
@@ -21608,21 +22440,22 @@ var GroupAuthorOrder = class {
21608
22440
  const newer = mine.filter((r) => !after(from, r)).sort((a, b) => after(a, b) ? 1 : after(b, a) ? -1 : 0);
21609
22441
  const ids = new Set(newer.map((r) => r.id));
21610
22442
  const first = rows.findIndex((r) => ids.has(r.id));
21611
- let end = rows.length;
21612
- for (let i = first + 1, top = rows[first]; i < rows.length; i++) {
21613
- if (rows[i].sender !== sender) continue;
21614
- if (after(top, rows[i])) {
21615
- end = i;
22443
+ let end = Infinity, top = rows[first];
22444
+ for (const r of rows.filter((r) => r.n > rows[first].n).sort((a, b) => a.n - b.n)) {
22445
+ if (r.sender !== sender) continue;
22446
+ if (after(top, r)) {
22447
+ end = r.n;
21616
22448
  break;
21617
22449
  }
21618
- top = rows[i];
22450
+ top = r;
21619
22451
  }
21620
- const followers = rows.slice(first, end).filter((r) => r.sender !== sender);
22452
+ const followers = rows.slice(first).filter((r) => r.sender !== sender && r.n < end);
21621
22453
  for (const r of followers) ids.add(r.id);
21622
22454
  const moved = [...newer, ...followers];
21623
22455
  this.rows.set(groupId, [...rows.filter((r) => !ids.has(r.id)), ...moved.map((r) => ({
21624
22456
  ...r,
21625
- at: Math.max(r.at, now)
22457
+ at: Math.max(r.at, now),
22458
+ n: ++this.came
21626
22459
  }))]);
21627
22460
  await this.host.placeAgain(this.linkOf(groupId), moved.map((r) => r.id)).catch(() => {});
21628
22461
  }
@@ -21632,8 +22465,8 @@ var GroupAuthorOrder = class {
21632
22465
  }
21633
22466
  }
21634
22467
  };
21635
- //#endregion
21636
- //#region ../browser/src/engine/groupLineOrder.ts
22468
+ /** How long the lines wait at most while older messages keep coming: one move every so often in a long catch-up. */
22469
+ var LINE_ORDER_WAIT_MAX_MS = AUTHOR_ORDER_SETTLE_MS;
21637
22470
  /**
21638
22471
  * The lines of a group's history ("… joined", "… is no longer a member") among the messages of their moment, for a
21639
22472
  * member back from away (WISP 902 § Catch-up). A message sealed under an epoch was written before its author knew of
@@ -21642,30 +22475,39 @@ var GroupAuthorOrder = class {
21642
22475
  * its epoch) and, in a private group, the answers of several members race, so the messages of the epoch before can come
21643
22476
  * after the line. Then the line, and the rows after it written knowing it, are placed again below them, in their order;
21644
22477
  * no clock is read. A row only ever moves down, and only rows placed here in the last few minutes, so a live
21645
- * conversation read long ago never moves. Moves made meanwhile go in one turn: a row moves about once a catch-up page.
22478
+ * conversation read long ago never moves. The lines wait for the rest of the older messages (`tick`), and while
22479
+ * messages of an epoch before are still on their way (`onTheirWay`) the rows wait for them, as `GroupAuthorOrder`'s do:
22480
+ * a row moves about once a catch-up.
21646
22481
  */
21647
22482
  var GroupLineOrder = class {
21648
22483
  host;
22484
+ onTheirWay;
21649
22485
  /** Per history: the rows followed, in the order they are placed in it. */
21650
22486
  rows = /* @__PURE__ */ new Map();
22487
+ /** Per history with rows out of order: since when, and when the last one came. */
22488
+ waiting = /* @__PURE__ */ new Map();
21651
22489
  due = /* @__PURE__ */ new Set();
21652
22490
  running = /* @__PURE__ */ new Set();
21653
- constructor(host) {
22491
+ constructor(host, onTheirWay = () => false) {
21654
22492
  this.host = host;
22493
+ this.onTheirWay = onTheirWay;
21655
22494
  }
21656
22495
  /**
21657
22496
  * A row is being stored, a line of the history or a message sealed under this epoch, and takes its place now: `at` is
21658
22497
  * when, on the engine's clock. Call what this returns with whether it was new: a message below a line of a later
21659
- * epoch takes the line (and the rows after it written knowing it) below it.
22498
+ * epoch takes the line (and the rows after it written knowing it) below it. `above`: the row it is stored just above
22499
+ * (`GroupAuthorOrder.above`); else it goes to the end.
21660
22500
  */
21661
- placing(linkId, row, at) {
22501
+ placing(linkId, row, at, above) {
21662
22502
  if (!this.host.placeAgain) return () => {};
21663
22503
  const followed = {
21664
22504
  ...row,
21665
22505
  at
21666
22506
  };
21667
22507
  const rows = (this.rows.get(linkId) ?? []).filter((r) => r.at > at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21668
- rows.push(followed);
22508
+ const index = above === void 0 ? -1 : rows.findIndex((r) => r.id === above);
22509
+ if (index >= 0) rows.splice(index, 0, followed);
22510
+ else rows.push(followed);
21669
22511
  this.rows.set(linkId, rows);
21670
22512
  return (stored) => {
21671
22513
  const now = this.rows.get(linkId);
@@ -21673,23 +22515,46 @@ var GroupLineOrder = class {
21673
22515
  if (now) this.rows.set(linkId, now.filter((r) => r !== followed));
21674
22516
  return;
21675
22517
  }
21676
- if (!followed.line && now?.some((r) => r.line && r.epoch > followed.epoch && now.indexOf(r) < now.indexOf(followed))) this.run(linkId);
22518
+ if (followed.line || !now) return;
22519
+ const line = now.findIndex((r) => r.line && r.epoch > followed.epoch);
22520
+ if (line < 0 || line > now.indexOf(followed)) return;
22521
+ for (const r of now.slice(line)) r.at = Math.max(r.at, at);
22522
+ this.wait(linkId, at);
21677
22523
  };
21678
22524
  }
21679
- /** These rows were placed again below everything (`GroupAuthorOrder`), in this order. */
21680
- moved(linkId, ids) {
22525
+ /** These rows were placed again below everything (`GroupAuthorOrder`), in this order, at `at` on the engine's clock. */
22526
+ moved(linkId, ids, at) {
21681
22527
  const rows = this.rows.get(linkId);
21682
22528
  if (!rows) return;
21683
22529
  const moved = ids.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
21684
22530
  if (!moved.length) return;
21685
22531
  this.rows.set(linkId, [...rows.filter((r) => !moved.includes(r)), ...moved]);
21686
- if (moved.some((r) => !r.line)) this.run(linkId);
22532
+ if (moved.some((r) => !r.line)) this.wait(linkId, at);
22533
+ }
22534
+ /**
22535
+ * Time passed: histories no older message came to for a moment, and nothing more is on its way for, or that waited
22536
+ * long enough, are put in order.
22537
+ */
22538
+ tick(now) {
22539
+ for (const [linkId, { since, last }] of this.waiting) {
22540
+ if (now - last < 3e3 && now - since < LINE_ORDER_WAIT_MAX_MS) continue;
22541
+ if (now - last < 15e3 && now - since < 9e4 && this.onTheirWay(linkId)) continue;
22542
+ this.waiting.delete(linkId);
22543
+ this.run(linkId);
22544
+ }
21687
22545
  }
21688
22546
  /** The group's history is gone (left or forgotten). */
21689
22547
  forget(linkId) {
21690
22548
  this.rows.delete(linkId);
22549
+ this.waiting.delete(linkId);
21691
22550
  this.due.delete(linkId);
21692
22551
  }
22552
+ wait(linkId, at) {
22553
+ this.waiting.set(linkId, {
22554
+ since: this.waiting.get(linkId)?.since ?? at,
22555
+ last: at
22556
+ });
22557
+ }
21693
22558
  /** The history's rows put in order; what comes while a move is written goes in the next turn. */
21694
22559
  run(linkId) {
21695
22560
  this.due.add(linkId);
@@ -21705,8 +22570,9 @@ var GroupLineOrder = class {
21705
22570
  if (above < 0) order.push(row);
21706
22571
  else order.splice(above, 0, row);
21707
22572
  }
22573
+ const place = new Map(rows.map((r, i) => [r, i]));
21708
22574
  let from = 0;
21709
- while (from < rows.length && rows[from] === order[from]) from++;
22575
+ while (from < order.length && (from === 0 || place.get(order[from]) > place.get(order[from - 1]))) from++;
21710
22576
  if (from === rows.length) continue;
21711
22577
  const last = rows[rows.length - 1].at;
21712
22578
  for (const r of order.slice(from)) r.at = Math.max(r.at, last);
@@ -21892,6 +22758,14 @@ var CUT_OFF_STEP_UP_MS = 15e3;
21892
22758
  /** A lobby request this recent is from a member still looking fast for my side of the edge: mine opens expecting it. */
21893
22759
  var LOBBY_EXPECT_MS = 3e4;
21894
22760
  /**
22761
+ * A member whose edge was down `memberGoneMs` is dropped by its hub, and its edge closed: its app closed, and is often
22762
+ * opened again within minutes, when it dials an edge my side no longer has and asks in my lobby. So my lobby is read at
22763
+ * the busy pace this long after, as a 1:1 chat watches a contact that went away (`WATCH_PEER_MS`). Read every half minute,
22764
+ * a web member back two minutes after it closed got the lines written meanwhile 30-45 s after it opened, while a 1:1
22765
+ * text from the same bot came in seconds (bug hunt 1013-2254, 2026-10-14).
22766
+ */
22767
+ var MEMBER_BACK_MS = WATCH_PEER_MS;
22768
+ /**
21895
22769
  * A lobby is one record every member asking a hub writes, each reading it and writing it back with its own request: two
21896
22770
  * members writing at once (both cut off by the same leave, in the same second) each write what they read, and the later
21897
22771
  * write drops the earlier request. So a member reads the lobby back this long after its write (plus up to `LOBBY_CHECK_JITTER_MS`, so
@@ -22352,10 +23226,10 @@ var Communities = class {
22352
23226
  await this.require(groupId).rotate();
22353
23227
  }
22354
23228
  async setPicture(groupId, picture) {
22355
- await this.require(groupId).setPicture(picture);
23229
+ await this.require(groupId).setPicture(picture, this.now());
22356
23230
  }
22357
23231
  async rename(groupId, name) {
22358
- await this.require(groupId).rename(name);
23232
+ await this.require(groupId).rename(name, this.now());
22359
23233
  }
22360
23234
  /** A new link (the old one reaches nobody), or none. */
22361
23235
  async replaceLink(groupId, off = false) {
@@ -22418,6 +23292,7 @@ var Communities = class {
22418
23292
  /** Everything timed: beacon, hub election, lobbies, knocks, leaves, edges. The engine calls it every second or so. */
22419
23293
  async tick(now = Date.now()) {
22420
23294
  this.time = now;
23295
+ this.lineOrder.tick(now);
22421
23296
  const active = [...this.live.values()].filter((l) => l.session.status === "active");
22422
23297
  this.sharing = {
22423
23298
  groups: Math.max(1, active.length),
@@ -22551,7 +23426,7 @@ var Communities = class {
22551
23426
  const busy = this.admitting(groupId, live, now);
22552
23427
  if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
22553
23428
  if (this.hubDropped(groupId, live, others, now)) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
22554
- const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
23429
+ const lobbyEvery = (now < live.lobbyBusyUntil || now < (live.memberBackUntil ?? 0)) && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
22555
23430
  if (now - live.lastLobbyPoll >= lobbyEvery) {
22556
23431
  live.lastLobbyPoll = now;
22557
23432
  await this.pollLobby(groupId, live, now);
@@ -22595,6 +23470,9 @@ var Communities = class {
22595
23470
  }
22596
23471
  let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
22597
23472
  kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
23473
+ if (!kept.length) {
23474
+ for (const key of live.restored.keys()) if (kept.length < COMMUNITY_TOPOLOGY.hubsPerMember && !live.hubsAvoided.has(key) && others.some((h) => h.key === key && h.load < COMMUNITY_TOPOLOGY.hubCapacity)) kept.push(key);
23475
+ }
22598
23476
  const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
22599
23477
  const bare = ![...edges.values()].some((id) => this.host.linkReady(id, 2));
22600
23478
  for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) {
@@ -22693,7 +23571,11 @@ var Communities = class {
22693
23571
  const id = edges.get(key);
22694
23572
  if (this.gone(live, key)) live.members.delete(key);
22695
23573
  else if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
22696
- else if (now - since > this.timings.memberGoneMs || live.session.wasRemoved(key)) live.members.delete(key);
23574
+ else if (live.session.wasRemoved(key)) live.members.delete(key);
23575
+ else if (now - since > this.timings.memberGoneMs) {
23576
+ live.members.delete(key);
23577
+ live.memberBackUntil = now + MEMBER_BACK_MS;
23578
+ }
22697
23579
  }
22698
23580
  this.shed(groupId, live, now);
22699
23581
  return live.members.size;
@@ -22746,7 +23628,8 @@ var Communities = class {
22746
23628
  async readBeacon(groupId, live, now) {
22747
23629
  live.lastBeaconRead = now;
22748
23630
  const keys = beaconKeys(live.session.state.rv, groupId);
22749
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => void 0);
23631
+ const back = !live.beaconAt && this.wayBack(groupId, live);
23632
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, !back, !back && this.doorBusy(groupId, live, now), back).catch(() => void 0);
22750
23633
  if (records === void 0) {
22751
23634
  live.beaconFailedAt = now;
22752
23635
  live.beaconAskedAt ||= now;
@@ -22779,9 +23662,10 @@ var Communities = class {
22779
23662
  async publishBeacon(groupId, live, now, listed) {
22780
23663
  live.lastBeaconTry = now;
22781
23664
  const keys = beaconKeys(live.session.state.rv, groupId);
23665
+ const back = live.hub && this.wayBack(groupId, live);
22782
23666
  let existing = live.beacon, head = live.head;
22783
23667
  if (live.lastBeaconRead !== now || live.beaconFailedAt === now) {
22784
- const read = await this.host.resolve(keys.identity.pubKeyZ32, true, live.hub).catch(() => void 0);
23668
+ const read = await this.host.resolve(keys.identity.pubKeyZ32, !back, live.hub && !back, back).catch(() => void 0);
22785
23669
  if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
22786
23670
  const records = read ?? [];
22787
23671
  existing = live.hubClocks.inMyTime(readBeacon(keys, records), now);
@@ -22794,7 +23678,7 @@ var Communities = class {
22794
23678
  since: live.hubSince || now
22795
23679
  } : null, now, (key) => !this.gone(live, key));
22796
23680
  const newest = newerHead(head, this.newestFrame(live), now);
22797
- await this.host.publish(keys.identity, beaconRecords(keys, live.hubClocks.asWritten(hubs), newest), true, live.hub);
23681
+ await this.host.publish(keys.identity, beaconRecords(keys, live.hubClocks.asWritten(hubs), newest), !back, live.hub && !back, back);
22798
23682
  live.beacon = hubs;
22799
23683
  live.head = newest;
22800
23684
  this.noteHubs(live, now);
@@ -22926,12 +23810,13 @@ var Communities = class {
22926
23810
  /** A member asks a hub for an edge: its key in the hub's lobby, and its side of the edge started. */
22927
23811
  async askHub(groupId, live, hub, now) {
22928
23812
  const keys = lobbyKeys(live.session.state.rv, groupId, hub);
22929
- const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true) ?? []);
23813
+ const back = this.wayBack(groupId, live);
23814
+ const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, !back, !back, back) ?? []);
22930
23815
  const theirs = now - live.hubClocks.ahead(hub);
22931
23816
  await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
22932
23817
  key: live.session.myKey,
22933
23818
  ts: theirs
22934
- }, theirs)), true, true);
23819
+ }, theirs)), !back, !back, back);
22935
23820
  (live.lobbyChecks ??= /* @__PURE__ */ new Map()).set(hub, {
22936
23821
  at: now + LOBBY_CHECK_MS + Math.floor(this.random() * LOBBY_CHECK_JITTER_MS),
22937
23822
  ts: Math.floor(theirs / 1e3) * 1e3
@@ -22954,7 +23839,8 @@ var Communities = class {
22954
23839
  }
22955
23840
  async pollLobby(groupId, live, now) {
22956
23841
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
22957
- const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => null) ?? []);
23842
+ const back = this.wayBack(groupId, live);
23843
+ const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, !back, !back && this.doorBusy(groupId, live, now), back).catch(() => null) ?? []);
22958
23844
  for (const { key, ts } of entries) {
22959
23845
  if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
22960
23846
  if (live.session.wasRemoved(key)) {
@@ -23055,6 +23941,34 @@ var Communities = class {
23055
23941
  return live.hub && (now < live.warmUntil || now < live.crowdUntil || now < live.lobbyBusyUntil || this.admitting(groupId, live, now));
23056
23942
  }
23057
23943
  /**
23944
+ * A member with no edge up in this community: its first reading of the beacon (who the hubs are) and its request in a
23945
+ * hub's lobby are its way back, and go within the relays' allowance for it (`PkarrRequestOptions.back`), not as
23946
+ * background looks. Those wait for every link that signals and every chat's look: a web app opened with 30 chats whose
23947
+ * contacts were online read none of its 5 communities' beacons in 3 minutes, dropped the edges of its last run at 60 s,
23948
+ * and got no community line, neither those written while it was closed nor new ones (bug hunt, 2026-10-13). Nor as a
23949
+ * group edge's signaling: the edges of its 15 groups, all offering at once, spent that allowance in the first second,
23950
+ * and the beacons were read a minute later or not at all (bug hunt, 2026-10-14). Bounded: the reading is asked again
23951
+ * only when refused, and a hub that does not take me is given up after its wait (`hubWaitMs`).
23952
+ *
23953
+ * A hub with no edge up too (an app back from a restart steps up at once where it was one, before any edge is up): its
23954
+ * beacon entry, read and written, and its reading of its lobby. As background looks, a daemon back as the hub of its 5
23955
+ * communities while its 30 chats signaled got back into them one at a time, 4 to 10 minutes, one not at all in 16: its
23956
+ * entry went unwritten, the other hubs took it for gone (`hubDead`) and its members asked them (bug hunt, 2026-10-14).
23957
+ * Not a hub alone in the beacon: nobody carries the group without it, and its door's looks stay background ones.
23958
+ *
23959
+ * Alone as my last reading of the beacon said, and only while that reading is fresh. One that aged out says nothing of
23960
+ * now: judged by my clock against it, every hub it listed went stale, and I was alone. An app back from a restart as a
23961
+ * member of a community whose hubs did not list it read the beacon once (its way back); its edge stayed down while its
23962
+ * other links took its relays' budget, its reading aged out, it stepped up as a hub "alone", and its readings and its
23963
+ * entry went as background looks the relays held: it never read the beacon again, nobody listed it, and it was not
23964
+ * back in that community in 10 minutes (bug hunt, 2026-10-17).
23965
+ */
23966
+ wayBack(groupId, live) {
23967
+ const read = live.beaconAt, now = this.now();
23968
+ if (live.hub && now - read < COMMUNITY_TOPOLOGY.beaconFreshMs && !freshHubs(this.hubs(live), read).some((h) => h.key !== live.session.myKey)) return false;
23969
+ return ![...this.host.edges(groupId).values()].some((id) => this.host.linkReady(id, 2));
23970
+ }
23971
+ /**
23058
23972
  * Which knock records to read now (none, most of the time): every one at once the first time
23059
23973
  * (a hub just elected, an app back, a change of door), since a knock left while nobody was at the
23060
23974
  * door may be in any; then the door the bell, and the others in turn; the other hubs one in turn, slowly.
@@ -23521,6 +24435,7 @@ var Communities = class {
23521
24435
  noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
23522
24436
  },
23523
24437
  app: (m) => this.deliver(id, () => m.frame.t === "group-have" ? this.files?.communityApp(id, m.sender, m.frame) : this.host.communityApp?.(id, m.sender, m.frame)),
24438
+ lasting: (m) => m.frame.t === "pin" && m.sender === session.admin ? "pin" : void 0,
23524
24439
  pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
23525
24440
  changed: () => {
23526
24441
  this.membershipChanged(id);
@@ -24768,6 +25683,8 @@ var Groups = class {
24768
25683
  /** Admin side: entry sessions of a member key that timed out in a row, and when the last did. */
24769
25684
  entryTimeouts = /* @__PURE__ */ new Map();
24770
25685
  lastPoll = /* @__PURE__ */ new Map();
25686
+ /** Admin side: when a link nobody is using was last read. Those links share one `pollMs` between them, read in turn. */
25687
+ lastQuietPoll = 0;
24771
25688
  /** Admin side: until when a group's link is looked at the warm pace. */
24772
25689
  warmUntil = /* @__PURE__ */ new Map();
24773
25690
  lastKnock = /* @__PURE__ */ new Map();
@@ -24798,6 +25715,12 @@ var Groups = class {
24798
25715
  * whom, and when it was asked. Their sync goes once that answer has come.
24799
25716
  */
24800
25717
  syncHeld = /* @__PURE__ */ new Map();
25718
+ /**
25719
+ * Per mesh group, the members told where I am since their edge came up (`edgeReady`), forgotten when a tick sees it
25720
+ * down. One whose edge is up but not told yet opened in the same moment as the one being told: it is asked of that
25721
+ * one, as if its edge were still down (`askOf`), and no gossip turn or ask for gaps goes to it before it is told.
25722
+ */
25723
+ told = /* @__PURE__ */ new Map();
24801
25724
  /** Per mesh group and member, when a message last came over its edge (its own, or one it handed on). */
24802
25725
  lastHanded = /* @__PURE__ */ new Map();
24803
25726
  /** Admin side, per group and member let in: when its welcome was last sent again, over its chat or the link (`REWELCOME_MS`). */
@@ -24863,16 +25786,16 @@ var Groups = class {
24863
25786
  }
24864
25787
  }, hubTimings);
24865
25788
  this.typings = new GroupTypings(host);
24866
- this.lineOrder = new GroupLineOrder(host);
25789
+ this.lineOrder = new GroupLineOrder(host, (linkId) => {
25790
+ const groupId = linkId.slice(MESSAGE_LINK("").length), session = this.sessions.get(groupId);
25791
+ return !!session && [...session.lacking().keys()].some((sender) => this.onTheirWay(groupId, sender, session.epoch));
25792
+ });
24867
25793
  const placeAgain = host.placeAgain && (async (linkId, ids) => {
24868
25794
  const done = host.placeAgain(linkId, ids);
24869
- this.lineOrder.moved(linkId, ids);
25795
+ this.lineOrder.moved(linkId, ids, this.now());
24870
25796
  await done;
24871
25797
  });
24872
- this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => {
24873
- const session = this.sessions.get(groupId);
24874
- return !!session && session.missing(sender, this.gapsGone.get(groupId)?.get(sender)?.[session.epoch]) > 0;
24875
- });
25798
+ this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => this.onTheirWay(groupId, sender));
24876
25799
  this.signals = new MeshSignals(host, {
24877
25800
  session: (id) => this.sessions.get(id),
24878
25801
  stored: (id) => this.stored.get(id),
@@ -25124,10 +26047,11 @@ var Groups = class {
25124
26047
  if (this.isCommunity(groupId)) throw new Error("Share the group's link with them: anyone who opens it joins");
25125
26048
  const session = this.session(groupId);
25126
26049
  if (!session.isAdmin) throw new Error("Only the admin can invite");
26050
+ if (this.host.adminWork?.(groupId) === false) throw new Error(GROUP_ADMIN_OFF_ERROR);
25127
26051
  const group = this.stored.get(groupId);
25128
26052
  if (Object.entries(group.contacts ?? {}).some(([key, id]) => id === linkId && rosterHas(session.roster, key))) throw new Error("This contact is already a member");
25129
26053
  if (session.roster.length + (this.invited.get(groupId)?.size ?? 0) >= GROUP_MEMBER_CAP.max) throw new Error(`A group holds ${GROUP_MEMBER_CAP.max} members at most`);
25130
- if (!this.host.linkReady(linkId)) throw new Error("Connect to this contact first. Their app needs groups (an updated Ghostly).");
26054
+ if (!this.host.linkReady(linkId)) throw new Error(this.host.linkOpen?.(linkId) === false ? "This contact is not connected right now. Invite them when they are online." : "Their app does not take part in groups: they need an updated Ghostly first.");
25131
26055
  const blocked = this.growthBlocked(group, session, session.roster.length + (this.invited.get(groupId)?.size ?? 0) + 1, linkId);
25132
26056
  if (blocked) throw new Error(blocked);
25133
26057
  this.host.sendOnLink(linkId, session.inviteFrame());
@@ -25654,6 +26578,7 @@ var Groups = class {
25654
26578
  this.lingering.delete(groupId);
25655
26579
  this.relayAsked.delete(groupId);
25656
26580
  this.syncHeld.delete(groupId);
26581
+ this.told.delete(groupId);
25657
26582
  this.lastHanded.delete(groupId);
25658
26583
  this.rewelcomed.delete(groupId);
25659
26584
  this.hubs.forget(groupId);
@@ -25769,6 +26694,8 @@ var Groups = class {
25769
26694
  this.ticking = true;
25770
26695
  try {
25771
26696
  this.authorOrder.tick(now);
26697
+ this.lineOrder.tick(now);
26698
+ const quiet = [];
25772
26699
  for (const group of [...this.stored.values()]) {
25773
26700
  if (group.left) {
25774
26701
  if (now - group.left.at > LEFT_KEPT_MS) await this.forget(group.id);
@@ -25810,12 +26737,25 @@ var Groups = class {
25810
26737
  const linkId = this.host.entries(group.id).get(key);
25811
26738
  if (linkId) await this.host.closeEdge(linkId);
25812
26739
  }
25813
- const every = now < (this.warmUntil.get(group.id) ?? 0) ? this.timings.warmPollMs : this.timings.pollMs;
25814
- if (!((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || session.roster.length + (pending?.size ?? 0) >= GROUP_MEMBER_CAP.max) && now - (this.lastPoll.get(group.id) ?? 0) >= every) {
26740
+ const busy = (pending?.size ?? 0) >= MAX_PENDING_ENTRIES || session.roster.length + (pending?.size ?? 0) >= GROUP_MEMBER_CAP.max;
26741
+ const since = now - (this.lastPoll.get(group.id) ?? 0);
26742
+ if (busy) continue;
26743
+ if (now >= (this.warmUntil.get(group.id) ?? 0)) {
26744
+ if (since >= this.timings.pollMs) quiet.push(group.id);
26745
+ } else if (since >= this.timings.warmPollMs) {
25815
26746
  this.lastPoll.set(group.id, now);
25816
26747
  await this.answerKnocks(group, session, now).catch(() => {});
25817
26748
  }
25818
26749
  }
26750
+ if (quiet.length && now - this.lastQuietPoll >= this.timings.pollMs) {
26751
+ const groupId = quiet.reduce((a, b) => (this.lastPoll.get(b) ?? 0) < (this.lastPoll.get(a) ?? 0) ? b : a);
26752
+ const group = this.stored.get(groupId), session = this.sessions.get(groupId);
26753
+ if (group?.entry && !group.left && session?.isAdmin) {
26754
+ this.lastQuietPoll = now;
26755
+ this.lastPoll.set(groupId, now);
26756
+ await this.answerKnocks(group, session, now).catch(() => {});
26757
+ }
26758
+ }
25819
26759
  for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
25820
26760
  for (const [key, { at }] of this.entryTimeouts) if (now - at > 36e5) this.entryTimeouts.delete(key);
25821
26761
  for (const [groupId, kept] of this.lingering) if (now >= kept.until) await this.endLinger(groupId);
@@ -25919,7 +26859,16 @@ var Groups = class {
25919
26859
  /** The member at the other end of an entry session (the joiner, on the admin's side) or of a chat an invitation went over. */
25920
26860
  linkMember(groupId, linkId) {
25921
26861
  for (const [peer, id] of this.host.entries(groupId)) if (id === linkId) return peer;
25922
- return Object.entries(this.stored.get(groupId)?.contacts ?? {}).find(([, id]) => id === linkId)?.[0];
26862
+ return this.chatMember(groupId, linkId);
26863
+ }
26864
+ /**
26865
+ * The member a chat an invitation went over is: one removed and invited back over the same chat has its old key on it
26866
+ * too (`contacts` keeps it for the removal notice), so the key in the roster, else the newest.
26867
+ */
26868
+ chatMember(groupId, linkId) {
26869
+ const keys = Object.entries(this.stored.get(groupId)?.contacts ?? {}).filter(([, id]) => id === linkId).map(([key]) => key);
26870
+ const roster = this.sessions.get(groupId)?.roster;
26871
+ return roster && keys.find((key) => rosterHas(roster, key)) || keys[keys.length - 1];
25923
26872
  }
25924
26873
  /**
25925
26874
  * My packet for the edge to `to`, handed to links that reach that member or may pass it on (WISP 902 · Group Mesh
@@ -26312,8 +27261,7 @@ var Groups = class {
26312
27261
  }
26313
27262
  case "group-leave": {
26314
27263
  const session = this.sessions.get(g);
26315
- const group = this.stored.get(g);
26316
- const member = Object.entries(group?.contacts ?? {}).find(([, id]) => id === linkId)?.[0];
27264
+ const member = this.chatMember(g, linkId);
26317
27265
  if (!session?.isAdmin || !member || member === session.myKey) return;
26318
27266
  if (rosterHas(session.roster, member)) await session.remove(member, Date.now(), { left: true }).catch(() => {});
26319
27267
  try {
@@ -26457,6 +27405,9 @@ var Groups = class {
26457
27405
  return;
26458
27406
  }
26459
27407
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
27408
+ let told = this.told.get(groupId);
27409
+ if (!told) this.told.set(groupId, told = /* @__PURE__ */ new Set());
27410
+ told.add(peerKey);
26460
27411
  const seen = !!group.unseen?.includes(peerKey);
26461
27412
  if (seen) {
26462
27413
  const unseen = group.unseen.filter((key) => key !== peerKey);
@@ -26466,7 +27417,7 @@ var Groups = class {
26466
27417
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
26467
27418
  if (large || legacy || seen) this.store.putGroup(group).catch(() => {});
26468
27419
  if (!this.holdSync(groupId, peerKey)) try {
26469
- this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey)));
27420
+ this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey), peerKey));
26470
27421
  } catch {
26471
27422
  return;
26472
27423
  }
@@ -26602,18 +27553,21 @@ var Groups = class {
26602
27553
  }
26603
27554
  /**
26604
27555
  * The unreachable members whose messages `via` should hand on to me: each is asked of one connected member at a
26605
- * time, so a member whose edges open one after another is not sent the same messages by each of them.
27556
+ * time, so a member whose edges open one after another is not sent the same messages by each of them. Those whose
27557
+ * edge opened in the same moment, not told yet, are asked of `via` too, and their syncs wait for its answer
27558
+ * (`holdSync`): each told at once answered with its own lines alone, and a conversation two of them had while I was
27559
+ * away came as all of one's lines, then all of the other's.
26606
27560
  */
26607
27561
  askOf(groupId, session, via) {
26608
27562
  let asked = this.relayAsked.get(groupId);
26609
27563
  if (!asked) this.relayAsked.set(groupId, asked = /* @__PURE__ */ new Map());
26610
- const edges = this.host.edges(groupId), now = this.now();
27564
+ const edges = this.host.edges(groupId), now = this.now(), told = this.told.get(groupId);
26611
27565
  const up = (key) => {
26612
27566
  const edge = edges.get(key);
26613
27567
  return !!edge && this.host.linkReady(edge);
26614
27568
  };
26615
27569
  const out = [];
26616
- for (const key of this.unreachable(groupId, session)) {
27570
+ for (const key of session.others.filter((key) => !up(key) || !told?.has(key))) {
26617
27571
  if (key === via) continue;
26618
27572
  const prior = asked.get(key);
26619
27573
  if (prior && up(prior.via) && now - prior.at < 6e4) continue;
@@ -26694,7 +27648,7 @@ var Groups = class {
26694
27648
  });
26695
27649
  }
26696
27650
  try {
26697
- this.host.sendOnLink(edges.get(next), session.syncFrame([...ask]));
27651
+ this.host.sendOnLink(edges.get(next), session.syncFrame([...ask], next));
26698
27652
  } catch {}
26699
27653
  }
26700
27654
  /**
@@ -26715,9 +27669,9 @@ var Groups = class {
26715
27669
  for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26716
27670
  const out = [];
26717
27671
  for (const [key, mark] of lacking) {
26718
- if (key === via || !up(key)) continue;
27672
+ if (key === via || !up(key) || !session.mayHold(via, key)) continue;
26719
27673
  const prior = asked.get(key);
26720
- if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4) continue;
27674
+ if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4 && session.mayHold(prior.via, key)) continue;
26721
27675
  let entry = tried.get(key);
26722
27676
  if (!entry || entry.mark !== mark) tried.set(key, entry = {
26723
27677
  mark,
@@ -26763,6 +27717,11 @@ var Groups = class {
26763
27717
  entry.at = now;
26764
27718
  return true;
26765
27719
  }
27720
+ /** Whether some of a member's messages are still on their way (not known gone): in any epoch, or in those below `below`. */
27721
+ onTheirWay(groupId, sender, below = Infinity) {
27722
+ const gone = this.gapsGone.get(groupId)?.get(sender);
27723
+ return Object.entries(this.sessions.get(groupId)?.missOf(sender) ?? {}).some(([e, gaps]) => Number(e) < below && gaps.some((n) => n > (gone?.[e] ?? -1)));
27724
+ }
26766
27725
  /**
26767
27726
  * Messages nobody holds any more: every connected member, their author included, was asked for the same missing
26768
27727
  * numbers `MESH_GAP_AGAIN_MS` ago or more and none arrived. An author keeps its last 32 and the others the group's
@@ -26776,7 +27735,7 @@ var Groups = class {
26776
27735
  for (const [key, mark] of lacking) {
26777
27736
  const entry = tried.get(key);
26778
27737
  if (!entry || entry.mark !== mark || entry.at === void 0 || now - entry.at < 1e4) continue;
26779
- if (!connected.includes(key) || !connected.every((member) => entry.of.has(member))) continue;
27738
+ if (!connected.includes(key) || !connected.every((member) => entry.of.has(member) || !session.mayHold(member, key))) continue;
26780
27739
  let gone = this.gapsGone.get(groupId);
26781
27740
  if (!gone) this.gapsGone.set(groupId, gone = /* @__PURE__ */ new Map());
26782
27741
  const upTo = { ...gone.get(key) };
@@ -26826,7 +27785,9 @@ var Groups = class {
26826
27785
  continue;
26827
27786
  }
26828
27787
  if (now - last.at < 1e4 || !up(last.via)) continue;
26829
- if (session.filledSince(key, last.asked)) again.set(last.via, [...again.get(last.via) ?? [], key]);
27788
+ if (!session.filledSince(key, last.asked)) continue;
27789
+ if (session.mayHold(last.via, key)) again.set(last.via, [...again.get(last.via) ?? [], key]);
27790
+ else fresh.set(key, mark);
26830
27791
  }
26831
27792
  const asked = this.relayAsked.get(groupId);
26832
27793
  for (const [via, keys] of again) {
@@ -26847,18 +27808,19 @@ var Groups = class {
26847
27808
  });
26848
27809
  }
26849
27810
  try {
26850
- this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
27811
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys, via));
26851
27812
  } catch {}
26852
27813
  }
26853
27814
  if (!fresh.size || !connected.length) return;
26854
27815
  const at = () => connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
27816
+ const helps = (member) => !fresh.has(member) && [...fresh.keys()].some((key) => session.mayHold(member, key));
26855
27817
  let via = at();
26856
- for (let i = 1; i < connected.length && fresh.has(via); i++) via = at();
27818
+ for (let i = 1; i < connected.length && !helps(via); i++) via = at();
26857
27819
  const keys = this.gapsOf(groupId, session, via, fresh);
26858
27820
  if (!keys.length) return;
26859
27821
  this.lastGossip.set(groupId, now);
26860
27822
  try {
26861
- this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
27823
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys, via));
26862
27824
  } catch {}
26863
27825
  }
26864
27826
  /**
@@ -26880,9 +27842,12 @@ var Groups = class {
26880
27842
  const edges = this.host.edges(groupId);
26881
27843
  if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
26882
27844
  }
26883
- const away = this.unreachable(groupId, session);
26884
- this.noteDown(groupId, session, away, now);
26885
- const connected = session.others.filter((key) => !away.includes(key));
27845
+ const down = this.unreachable(groupId, session);
27846
+ this.noteDown(groupId, session, down, now);
27847
+ const told = this.told.get(groupId);
27848
+ for (const key of down) told?.delete(key);
27849
+ const connected = session.others.filter((key) => !down.includes(key) && told?.has(key));
27850
+ const away = session.others.filter((key) => !connected.includes(key));
26886
27851
  this.gapsAgain(groupId, session, now, connected);
26887
27852
  if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
26888
27853
  const edges = this.host.edges(groupId);
@@ -26900,7 +27865,7 @@ var Groups = class {
26900
27865
  const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
26901
27866
  if (!this.ownGap(groupId, via, lacking) && !ask.length) continue;
26902
27867
  try {
26903
- this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
27868
+ this.host.sendOnLink(edges.get(via), session.syncFrame(ask, via));
26904
27869
  } catch {}
26905
27870
  }
26906
27871
  }
@@ -27004,21 +27969,27 @@ var Groups = class {
27004
27969
  ...m.frame && { frame: m.frame }
27005
27970
  };
27006
27971
  const completed = !!m.completes && !!this.host.completeMessage;
27972
+ const row = {
27973
+ sender: m.sender,
27974
+ epoch: m.epoch,
27975
+ seq: m.seq,
27976
+ id: m.id
27977
+ };
27978
+ const above = completed || m.sender === session.myKey ? void 0 : this.authorOrder.above(state.id, row);
27979
+ const place = above === void 0 ? void 0 : await this.host.placeAbove?.(message.linkId, above).catch(() => void 0);
27980
+ const placedAbove = place === void 0 ? void 0 : above;
27007
27981
  const placing = completed ? void 0 : this.lineOrder.placing(MESSAGE_LINK(state.id), {
27008
27982
  id: m.id,
27009
27983
  epoch: m.epoch,
27010
27984
  line: false
27011
- }, this.now());
27012
- const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
27985
+ }, this.now(), placedAbove);
27986
+ const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message, m.live, place);
27013
27987
  placing?.(stored !== false);
27014
27988
  if (m.frame && (completed || stored !== false)) await this.frameKept(state.id, m.id, m.frame);
27015
27989
  if (!completed && stored !== false) this.authorOrder.placed(state.id, {
27016
- sender: m.sender,
27017
- epoch: m.epoch,
27018
- seq: m.seq,
27019
- id: m.id,
27990
+ ...row,
27020
27991
  at: this.now()
27021
- });
27992
+ }, placedAbove);
27022
27993
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
27023
27994
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
27024
27995
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
@@ -30241,7 +31212,10 @@ var SWAP_POLL_MS = 3e4;
30241
31212
  var SWAP_SLOW_POLL_MS = 6e5;
30242
31213
  /** How long a swap whose inputs were given back is still asked about, in case the mint made it after all. */
30243
31214
  var SWAP_WATCH_MS = 36e5;
30244
- function reviewedCashuFee(preview) {
31215
+ /** Cashu's library (174 KiB) loads with the wallet's first use of a mint or a token, not with the app's start. */
31216
+ var loadCashu = () => import("@cashu/cashu-ts");
31217
+ async function reviewedCashuFee(preview) {
31218
+ const { Amount } = await loadCashu();
30245
31219
  const fee = preview.inputs.reduce((sum, proof) => sum + Amount.from(proof.amount).toNumber(), 0) - (preview.keepOutputs ?? []).reduce((sum, output) => sum + Amount.from(output.blindedMessage.amount).toNumber(), 0) - preview.amount.toNumber();
30246
31220
  if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
30247
31221
  return fee;
@@ -30297,9 +31271,9 @@ var toStored = (mint, p, reserved) => ({
30297
31271
  });
30298
31272
  /** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
30299
31273
  var TEST_COINS_NOTE = "Test coins from the test mint";
30300
- var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
31274
+ var swapId = () => `swap-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
30301
31275
  var walletTx = (mint, kind, amount, fee, note) => ({
30302
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
31276
+ id: `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
30303
31277
  timestamp: Date.now(),
30304
31278
  mint,
30305
31279
  kind,
@@ -30608,6 +31582,7 @@ var CashuWallet = class CashuWallet {
30608
31582
  const quote = await wrap((await store(STORES$1.quotes, "readonly")).get(id));
30609
31583
  if (!quote || quote.issuedUnclaimed || quote.held) return null;
30610
31584
  const wallet = await this.wallet(quote.mint);
31585
+ const { OutputData, isMintOperationError } = await loadCashu();
30611
31586
  const answer = await wallet.checkMintQuoteBolt11(quote.quote);
30612
31587
  const { state } = answer;
30613
31588
  if (state === "UNPAID") {
@@ -30651,7 +31626,10 @@ var CashuWallet = class CashuWallet {
30651
31626
  /** The ecash of a paid invoice goes in with its history line, in the transaction that drops the quote. */
30652
31627
  async storeMinted(quote, proofs) {
30653
31628
  const minted = sats(proofs);
30654
- const tx = walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0);
31629
+ const tx = {
31630
+ ...walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0),
31631
+ quote: quote.quote
31632
+ };
30655
31633
  await transact([
30656
31634
  STORES$1.proofs,
30657
31635
  STORES$1.walletTx,
@@ -30687,7 +31665,7 @@ var CashuWallet = class CashuWallet {
30687
31665
  if (await this.balanceAt(mint) < amount) continue;
30688
31666
  return this.locked(mint, async () => {
30689
31667
  const { inputs, keep, send, swap } = await this.split(mint, amount, await this.proofsAt(mint));
30690
- const token = getEncodedToken({
31668
+ const token = (await loadCashu()).getEncodedToken({
30691
31669
  mint,
30692
31670
  proofs: send,
30693
31671
  unit: UNIT$1
@@ -30721,10 +31699,10 @@ var CashuWallet = class CashuWallet {
30721
31699
  return this.locked(mint, async () => {
30722
31700
  const preview = await (await this.wallet(mint)).ops.send(amount, asProofLike(await this.proofsAt(mint))).includeFees(true).prepare();
30723
31701
  return {
30724
- fee: reviewedCashuFee(preview),
31702
+ fee: await reviewedCashuFee(preview),
30725
31703
  prepared: {
30726
31704
  mint,
30727
- swap: serializeSwapPreview(preview)
31705
+ swap: (await loadCashu()).serializeSwapPreview(preview)
30728
31706
  }
30729
31707
  };
30730
31708
  });
@@ -30732,8 +31710,8 @@ var CashuWallet = class CashuWallet {
30732
31710
  /** Reserve before the mint call. A timeout keeps these inputs reserved for read-only recovery. */
30733
31711
  async executeReviewedCashu(review, prepared) {
30734
31712
  return this.locked(prepared.mint, async () => {
30735
- const preview = deserializeSwapPreview(prepared.swap);
30736
- if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
31713
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
31714
+ if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || await reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
30737
31715
  const tx = (await openDb()).transaction(STORES$1.proofs, "readwrite");
30738
31716
  await new Promise((resolve, reject) => {
30739
31717
  const proofs = tx.objectStore(STORES$1.proofs);
@@ -30763,6 +31741,7 @@ var CashuWallet = class CashuWallet {
30763
31741
  const stored = await wrap((await store(STORES$1.payments, "readonly")).get(review.id));
30764
31742
  if (stored?.token) return stored.token;
30765
31743
  const wallet = await this.wallet(prepared.mint);
31744
+ const { OutputData } = await loadCashu();
30766
31745
  const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
30767
31746
  const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
30768
31747
  const outputs = [...keep, ...send];
@@ -30792,6 +31771,7 @@ var CashuWallet = class CashuWallet {
30792
31771
  if (recovered.outputs.length === 0 && recovered.signatures.length === 0) return "none";
30793
31772
  if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return "some";
30794
31773
  const keys = (await wallet.mint.getKeys()).keysets;
31774
+ const { Amount } = await loadCashu();
30795
31775
  const restored = [];
30796
31776
  for (const output of outputs) {
30797
31777
  const index = recovered.outputs.findIndex((o) => o.B_ === output.blindedMessage.B_);
@@ -30803,6 +31783,7 @@ var CashuWallet = class CashuWallet {
30803
31783
  return restored;
30804
31784
  }
30805
31785
  async finishReviewedCashu(review, prepared, keep, send) {
31786
+ const { getEncodedToken, deserializeSwapPreview } = await loadCashu();
30806
31787
  const token = getEncodedToken({
30807
31788
  mint: prepared.mint,
30808
31789
  proofs: send,
@@ -30845,6 +31826,7 @@ var CashuWallet = class CashuWallet {
30845
31826
  * not SPENT), and the mint signed none of its outputs. Read-only: it asks, and never sends the swap.
30846
31827
  */
30847
31828
  async reviewedCashuNeverSwapped(prepared) {
31829
+ const { OutputData, deserializeSwapPreview } = await loadCashu();
30848
31830
  const preview = deserializeSwapPreview(prepared.swap);
30849
31831
  if (!preview.inputs.length) return false;
30850
31832
  const wallet = await this.wallet(prepared.mint);
@@ -30863,6 +31845,7 @@ var CashuWallet = class CashuWallet {
30863
31845
  * pending. Read-only. A mint that does not restore cannot say, and the answer is no.
30864
31846
  */
30865
31847
  async reviewedCashuSpentElsewhere(prepared) {
31848
+ const { OutputData, deserializeSwapPreview } = await loadCashu();
30866
31849
  const preview = deserializeSwapPreview(prepared.swap);
30867
31850
  if (!preview.inputs.length) return false;
30868
31851
  const wallet = await this.wallet(prepared.mint);
@@ -30880,7 +31863,7 @@ var CashuWallet = class CashuWallet {
30880
31863
  * are free again, and the rest of the wallet's ecash at that mint is checked the same way. Idempotent.
30881
31864
  */
30882
31865
  async dropReviewedCashu(prepared) {
30883
- const preview = deserializeSwapPreview(prepared.swap);
31866
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
30884
31867
  await this.locked(prepared.mint, async () => {
30885
31868
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(preview.inputs.map((p) => ({
30886
31869
  secret: p.secret,
@@ -30925,7 +31908,7 @@ var CashuWallet = class CashuWallet {
30925
31908
  }
30926
31909
  /** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
30927
31910
  async releaseReviewedCashu(prepared) {
30928
- const preview = deserializeSwapPreview(prepared.swap);
31911
+ const preview = (await loadCashu()).deserializeSwapPreview(prepared.swap);
30929
31912
  await this.locked(prepared.mint, async () => {
30930
31913
  await transact([STORES$1.proofs], (stores) => {
30931
31914
  const proofs = stores[STORES$1.proofs];
@@ -30950,7 +31933,7 @@ var CashuWallet = class CashuWallet {
30950
31933
  return (await wrap((await store(STORES$1.payments, "readonly")).get(id)))?.state === "reclaimed";
30951
31934
  }
30952
31935
  async reviewedCashuSpent(prepared) {
30953
- const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
31936
+ const outputs = (prepared.swap.sendOutputs ?? []).map((await loadCashu()).OutputData.deserialize);
30954
31937
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
30955
31938
  secret: new TextDecoder().decode(o.secret),
30956
31939
  id: o.blindedMessage.id
@@ -30958,8 +31941,10 @@ var CashuWallet = class CashuWallet {
30958
31941
  return states.length > 0 && states.length === outputs.length && states.every((s) => s.state === "SPENT");
30959
31942
  }
30960
31943
  /** Reads a token or a payment request without contacting a mint. Nothing here moves money. */
30961
- inspect(text) {
31944
+ async inspect(text) {
30962
31945
  const value = text.trim();
31946
+ if (!/^(cashu|creq)[AB]/i.test(value)) return null;
31947
+ const { decodePaymentRequest, getTokenMetadata } = await loadCashu();
30963
31948
  try {
30964
31949
  if (/^cashu[AB]/i.test(value)) {
30965
31950
  const metadata = getTokenMetadata(value);
@@ -30994,6 +31979,7 @@ var CashuWallet = class CashuWallet {
30994
31979
  let mint;
30995
31980
  let faceValue;
30996
31981
  let unit;
31982
+ const { getTokenMetadata, serializeSwapPreview } = await loadCashu();
30997
31983
  try {
30998
31984
  const metadata = getTokenMetadata(token);
30999
31985
  unit = metadata.unit;
@@ -31064,6 +32050,7 @@ var CashuWallet = class CashuWallet {
31064
32050
  */
31065
32051
  async split(mint, amount, proofs) {
31066
32052
  const wallet = await this.wallet(mint);
32053
+ const { getEncodedToken, serializeSwapPreview } = await loadCashu();
31067
32054
  const among = (chosen) => {
31068
32055
  const secrets = new Set(chosen.map((p) => p.secret));
31069
32056
  return proofs.filter((p) => secrets.has(p.secret));
@@ -31115,6 +32102,7 @@ var CashuWallet = class CashuWallet {
31115
32102
  * `pollSwaps` settles it. Runs under the mint's lock.
31116
32103
  */
31117
32104
  async completeSwap(swap) {
32105
+ const { deserializeSwapPreview, isMintOperationError } = await loadCashu();
31118
32106
  try {
31119
32107
  return await (await this.wallet(swap.mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
31120
32108
  } catch (error) {
@@ -31142,6 +32130,7 @@ var CashuWallet = class CashuWallet {
31142
32130
  */
31143
32131
  async swapOutcome(swap, refused = false) {
31144
32132
  const wallet = await this.wallet(swap.mint);
32133
+ const { OutputData } = await loadCashu();
31145
32134
  const saved = swapOf(swap);
31146
32135
  const keep = (saved.keepOutputs ?? []).map(OutputData.deserialize);
31147
32136
  const send = (saved.sendOutputs ?? []).map(OutputData.deserialize);
@@ -31300,6 +32289,15 @@ var CashuWallet = class CashuWallet {
31300
32289
  settleSwaps(mints) {
31301
32290
  return this.pollSwaps(mints);
31302
32291
  }
32292
+ /**
32293
+ * A contact's ecash for this request of ours is being redeemed: written down, and the mint has not said how it ended,
32294
+ * or it has and its chat was not told yet. Released: the mint never made it in time, and the ecash went back. Stuck
32295
+ * past its watch hour (spent, and a mint that cannot say by what): kept to be asked about, but no longer expected to
32296
+ * pay the request, which another member may pay.
32297
+ */
32298
+ async redeemingFor(requestId) {
32299
+ return (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).some((s) => s.kind === "receive" && !s.released && s.payment?.requestId === requestId && !(s.stuck && Date.now() >= s.createdAt + SWAP_WATCH_MS));
32300
+ }
31303
32301
  async pollSwaps(mints) {
31304
32302
  if (!mints) {
31305
32303
  if (this.swapTimer) clearTimeout(this.swapTimer);
@@ -31347,34 +32345,36 @@ var CashuWallet = class CashuWallet {
31347
32345
  }));
31348
32346
  if (stuck === 1) this.events.onChange();
31349
32347
  later(wait);
31350
- } else if (outcome.idle && swap.kind === "receive" && !swap.released) try {
31351
- const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
31352
- if (await this.finishSwap(swap, keep, [], true)) {
31353
- const told = await this.tellSwap(swap, true);
31354
- if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
31355
- else if (swap.payment) later(SWAP_POLL_MS);
31356
- this.events.onChange();
31357
- }
31358
- } catch (error) {
31359
- swap = {
31360
- ...swap,
31361
- attemptEndedAt: Date.now()
31362
- };
31363
- await wrap((await store(STORES$1.swaps, "readwrite")).put(swap));
31364
- const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
31365
- if (refusal?.state === "done") {
31366
- if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
32348
+ } else if (outcome.idle && swap.kind === "receive" && !swap.released) {
32349
+ const { deserializeSwapPreview, isMintOperationError } = await loadCashu();
32350
+ try {
32351
+ const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
32352
+ if (await this.finishSwap(swap, keep, [], true)) {
31367
32353
  const told = await this.tellSwap(swap, true);
31368
32354
  if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
32355
+ else if (swap.payment) later(SWAP_POLL_MS);
32356
+ this.events.onChange();
31369
32357
  }
31370
- this.events.onChange();
31371
- } else if (refusal?.state === "never") {
31372
- await this.dropSwap(swap, refusal.gone);
31373
- await this.tellSwap(swap, false);
31374
- this.events.onChange();
31375
- } else later(SWAP_POLL_MS);
31376
- }
31377
- else if (outcome.pending && swap.kind === "receive" && !swap.released && Date.now() >= swapOverdueAt(swap)) {
32358
+ } catch (error) {
32359
+ swap = {
32360
+ ...swap,
32361
+ attemptEndedAt: Date.now()
32362
+ };
32363
+ await wrap((await store(STORES$1.swaps, "readwrite")).put(swap));
32364
+ const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
32365
+ if (refusal?.state === "done") {
32366
+ if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
32367
+ const told = await this.tellSwap(swap, true);
32368
+ if (swap.payment && told) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
32369
+ }
32370
+ this.events.onChange();
32371
+ } else if (refusal?.state === "never") {
32372
+ await this.dropSwap(swap, refusal.gone);
32373
+ await this.tellSwap(swap, false);
32374
+ this.events.onChange();
32375
+ } else later(SWAP_POLL_MS);
32376
+ }
32377
+ } else if (outcome.pending && swap.kind === "receive" && !swap.released && Date.now() >= swapOverdueAt(swap)) {
31378
32378
  if (Date.now() < swap.createdAt + SWAP_WATCH_MS) {
31379
32379
  await wrap((await store(STORES$1.swaps, "readwrite")).put({
31380
32380
  ...swap,
@@ -31468,6 +32468,7 @@ var CashuWallet = class CashuWallet {
31468
32468
  }
31469
32469
  async melt(quoteId, mint, note, paymentId) {
31470
32470
  const wallet = await this.wallet(mint);
32471
+ const { MeltChangeError, OutputData, isMintOperationError } = await loadCashu();
31471
32472
  const quote = await wallet.checkMeltQuoteBolt11(quoteId);
31472
32473
  if ((await wrap((await store(STORES$1.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
31473
32474
  const shown = this.shownQuotes.get(quoteId);
@@ -31561,6 +32562,7 @@ var CashuWallet = class CashuWallet {
31561
32562
  */
31562
32563
  async settleMelt(melt) {
31563
32564
  const wallet = await this.wallet(melt.mint);
32565
+ const { Amount, OutputData } = await loadCashu();
31564
32566
  const quote = await wallet.checkMeltQuoteBolt11(melt.quote);
31565
32567
  if (quote.state === "PAID") {
31566
32568
  const signatures = (quote.change ?? []).filter((sig) => !Amount.from(sig.amount).isZero());
@@ -31666,7 +32668,7 @@ var CashuWallet = class CashuWallet {
31666
32668
  out.push({
31667
32669
  mint,
31668
32670
  amount: total(proofs),
31669
- token: getEncodedToken({
32671
+ token: (await loadCashu()).getEncodedToken({
31670
32672
  mint,
31671
32673
  proofs,
31672
32674
  unit: UNIT$1
@@ -31679,7 +32681,7 @@ var CashuWallet = class CashuWallet {
31679
32681
  let wallet = this.wallets.get(mint);
31680
32682
  if (!wallet) {
31681
32683
  wallet = (async () => {
31682
- const instance = new Wallet(mint, { unit: UNIT$1 });
32684
+ const instance = new (await (loadCashu())).Wallet(mint, { unit: UNIT$1 });
31683
32685
  await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
31684
32686
  return instance;
31685
32687
  })();
@@ -32319,12 +33321,14 @@ var PaymentDesk = class {
32319
33321
  return request.linkId === linkId || !!request.group && this.host.groupOf?.(linkId) === request.group;
32320
33322
  }
32321
33323
  /**
32322
- * Why a token cannot pay a request to the group, checked before anything is redeemed: already paid, or it would
32323
- * not settle it (too little, a mint the request did not name). Undefined when it can.
33324
+ * Why a token cannot pay a request to the group, checked before anything is redeemed: already paid, another member's
33325
+ * ecash for it still being redeemed (the mint has not answered), or it would not settle it (too little, a mint the
33326
+ * request did not name). Undefined when it can.
32324
33327
  */
32325
- refuseForGroup(request, payment) {
33328
+ async refuseForGroup(request, payment) {
32326
33329
  if (request.state !== "pending") return "Already paid by another member of the group";
32327
- const token = this.wallet.inspect?.(payment.endpoint[1]);
33330
+ if (await this.wallet.redeemingFor?.(request.id)) return "Another member of the group is paying it";
33331
+ const token = await this.wallet.inspect?.(payment.endpoint[1]);
32328
33332
  if (token && token.kind === "token" && (token.amount < request.amount || !(request.mints ?? []).includes(token.mint))) return "This ecash does not pay the request: the amount or the mint differs";
32329
33333
  }
32330
33334
  /**
@@ -32416,6 +33420,24 @@ var PaymentDesk = class {
32416
33420
  }).catch(() => {});
32417
33421
  }
32418
33422
  /**
33423
+ * A Testnet invoice found in a chat message, paid from its card: the chat's contact is told as `sayPaid` does, with
33424
+ * no request (it may be one from their Receive screen, held until a payer says it paid).
33425
+ */
33426
+ sayInvoicePaid(linkId, invoice) {
33427
+ const link = this.host.getLink(linkId);
33428
+ const amount = decodeBolt11(invoice)?.amountSat;
33429
+ if (!link || !amount) return;
33430
+ this.sendTo(link, {
33431
+ id: newId(),
33432
+ timestamp: Date.now(),
33433
+ amount: {
33434
+ value: String(amount),
33435
+ asset: UNIT
33436
+ },
33437
+ endpoint: [ENDPOINT.bolt11, invoice.trim()]
33438
+ }).catch(() => {});
33439
+ }
33440
+ /**
32419
33441
  * Takes back ecash the peer never redeemed. If they did redeem it, the mint says so and the payment is settled.
32420
33442
  * Resolves to what came back of Cashu ecash (and the mint's fee for it); undefined when nothing did, or not Cashu.
32421
33443
  */
@@ -32752,7 +33774,7 @@ var PaymentDesk = class {
32752
33774
  });
32753
33775
  return;
32754
33776
  }
32755
- const inspected = this.wallet.inspect?.(payment.endpoint[1]);
33777
+ const inspected = await this.wallet.inspect?.(payment.endpoint[1]);
32756
33778
  if (payment.endpoint[0] === ENDPOINT.cashu && inspected !== void 0 && inspected?.kind !== "token") {
32757
33779
  link.sendPaymentResult({
32758
33780
  id: payment.id,
@@ -32771,7 +33793,7 @@ var PaymentDesk = class {
32771
33793
  }
32772
33794
  const forGroup = payment.requestId ? this.payments.get(payment.requestId) : void 0;
32773
33795
  if (forGroup?.group && forGroup.kind === "request" && forGroup.direction === "out" && this.owns(forGroup, linkId)) {
32774
- const refused = this.refuseForGroup(forGroup, payment);
33796
+ const refused = await this.refuseForGroup(forGroup, payment);
32775
33797
  if (refused) {
32776
33798
  link.sendPaymentResult({
32777
33799
  id: payment.id,
@@ -33039,6 +34061,7 @@ var PaymentDesk = class {
33039
34061
  */
33040
34062
  async receiveCheck(linkId, payment) {
33041
34063
  const link = this.host.getLink(linkId);
34064
+ if (link && !payment.requestId && payment.endpoint[0] === ENDPOINT.bolt11) return this.receiveInvoicePaid(linkId, link, payment.endpoint[1]);
33042
34065
  const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
33043
34066
  if (!link || !request || request.kind !== "request" || request.direction !== "out" || !this.owns(request, linkId)) return;
33044
34067
  const rail = request.target ? request.target.method : request.invoice ? "lightning" : void 0;
@@ -33061,6 +34084,22 @@ var PaymentDesk = class {
33061
34084
  else await this.reconcileArkReceipts();
33062
34085
  } catch {}
33063
34086
  }
34087
+ /**
34088
+ * The contact says it paid an invoice of ours it found in the chat, with no request: a Receive-screen invoice is
34089
+ * held on a test mint until then (StoredQuote `held`), and that one alone is let go. A request's invoice is spoken
34090
+ * for by its own contact only (`receiveCheck`). Bounded: one look every 3 seconds per chat.
34091
+ */
34092
+ async receiveInvoicePaid(linkId, link, invoice) {
34093
+ if (!link.allowsPayment("lightning")) return;
34094
+ const now = Date.now();
34095
+ if (now - (this.lastCheckFrom.get(`chat:${linkId}`) ?? 0) < 3e3) return;
34096
+ this.lastCheckFrom.set(`chat:${linkId}`, now);
34097
+ const wanted = invoice.trim().toLowerCase();
34098
+ try {
34099
+ const quote = (await this.wallet.quotes()).find((q) => q.held && !q.paymentId && q.invoice.toLowerCase() === wanted);
34100
+ if (quote) await this.wallet.vouch(quote.invoice);
34101
+ } catch {}
34102
+ }
33064
34103
  /** A Lightning payment of a contact's request that was left pending (or unknown) settled. */
33065
34104
  async onLightningResolved(op, paid) {
33066
34105
  const found = op.paymentId ? this.payments.get(op.paymentId) : void 0;
@@ -34505,7 +35544,7 @@ function writtenAt(message, leads) {
34505
35544
  }
34506
35545
  var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
34507
35546
  /** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
34508
- function messageAttention(type, message, { startedAt, newest, now }) {
35547
+ function messageAttention(type, message, { startedAt, newest, now, lateAt }) {
34509
35548
  if (message.timestamp < startedAt || message.file || message.paymentId || JOIN_NOTICE.test(message.text)) return null;
34510
35549
  if (message.event) {
34511
35550
  const cue = groupCue(message);
@@ -34521,6 +35560,7 @@ function messageAttention(type, message, { startedAt, newest, now }) {
34521
35560
  };
34522
35561
  if (!message.text.trim()) return null;
34523
35562
  if (newest !== void 0 && message.timestamp < newest - 6e4 && now - message.timestamp > 6e4) return null;
35563
+ if (lateAt !== void 0 && now - lateAt < 5e3 && now - message.timestamp > 6e4) return null;
34524
35564
  return {
34525
35565
  type,
34526
35566
  mention: !!message.mentioned
@@ -34959,6 +35999,11 @@ var GhostlyNode = class GhostlyNode {
34959
35999
  transfersRestored = false;
34960
36000
  /** `start` has loaded the groups (EngineState.groupsLoaded). */
34961
36001
  groupsLoaded = false;
36002
+ /** Settles as `start` has loaded the groups: a group's history reads as stored from then on (`EngineServer.handle`). */
36003
+ groupsLoadedNow;
36004
+ groupsRead = new Promise((resolve) => {
36005
+ this.groupsLoadedNow = resolve;
36006
+ });
34962
36007
  feedbackStartedAt = Date.now();
34963
36008
  feedbackIds = /* @__PURE__ */ new Set();
34964
36009
  walletFeedbackReady = false;
@@ -34983,28 +36028,43 @@ var GhostlyNode = class GhostlyNode {
34983
36028
  }
34984
36029
  /** The newest time written in each chat while this engine runs: a message written well before it is a catch-up. */
34985
36030
  newestAt = /* @__PURE__ */ new Map();
36031
+ /** Per group: when the last message written well before it came, came. What comes after it, as old, is its catch-up. */
36032
+ lateAt = /* @__PURE__ */ new Map();
34986
36033
  /** The last place given in each chat while this engine runs (`arrivalKey`). */
34987
36034
  placedAt = /* @__PURE__ */ new Map();
34988
36035
  /**
34989
- * Each 1:1 chat's contact: how far the time its latest live messages said was from when they came (`CLOCK_SAMPLES`
34990
- * of them). What tells a contact's clock that is behind from a message that is late (`writtenAt`).
36036
+ * Each 1:1 chat's contact, and each group's member (`clockOf`): how far the time its latest live messages said was
36037
+ * from when they came (`CLOCK_SAMPLES` of them). What tells a clock that is behind from a message that is late (`writtenAt`).
34991
36038
  */
34992
36039
  clockLeads = /* @__PURE__ */ new Map();
34993
- /** A contact's row came on the live session of a 1:1 chat: what it says of the contact's clock is kept. */
34994
- clockSample(message) {
34995
- if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink" || message.linkId.startsWith("group:")) return;
34996
- this.clockLeads.set(message.linkId, [...this.clockLeads.get(message.linkId) ?? [], message.sentAt - message.timestamp].slice(-5));
36040
+ /** Whose clock a row's time is: a 1:1 chat's contact, or the group's member who wrote it. */
36041
+ clockOf(message) {
36042
+ return message.linkId.startsWith("group:") ? `${message.linkId} ${message.member ?? ""}` : message.linkId;
36043
+ }
36044
+ /**
36045
+ * A contact's row came on the live session of a 1:1 chat, or a member's straight from its author (`live`, never one
36046
+ * handed on or caught up): what it says of that clock is kept.
36047
+ */
36048
+ clockSample(message, live = false) {
36049
+ if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink") return;
36050
+ if (message.linkId.startsWith("group:") && !(live && message.member)) return;
36051
+ const clock = this.clockOf(message);
36052
+ this.clockLeads.set(clock, [...this.clockLeads.get(clock) ?? [], message.sentAt - message.timestamp].slice(-5));
34997
36053
  }
34998
36054
  /** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
34999
36055
  messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
36056
+ const written = writtenAt(message, this.clockLeads.get(this.clockOf(message))), now = Date.now();
36057
+ const group = type === "message" && message.linkId.startsWith("group:"), lateAt = group ? this.lateAt.get(message.linkId) : void 0;
35000
36058
  const attention = messageAttention(type, {
35001
36059
  ...message,
35002
- timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
36060
+ timestamp: written
35003
36061
  }, {
35004
36062
  startedAt: this.feedbackStartedAt,
35005
36063
  newest,
35006
- now: Date.now()
36064
+ now,
36065
+ ...lateAt !== void 0 && { lateAt }
35007
36066
  });
36067
+ if (group && !message.event && now - written > 6e4) this.lateAt.set(message.linkId, now);
35008
36068
  if (attention) this.feedback(attention.type, message.linkId + ":" + message.id, message.linkId, attention.mention, attention.cue);
35009
36069
  }
35010
36070
  /**
@@ -35549,6 +36609,8 @@ var GhostlyNode = class GhostlyNode {
35549
36609
  }
35550
36610
  }
35551
36611
  });
36612
+ /** x401 credentials (WISP 3xx): kept for websites the headless CLI calls, never shared with a contact. */
36613
+ x401 = new X401Credentials();
35552
36614
  /** Identity proofs: this profile's, and those shared in each paired chat (WISP 300). */
35553
36615
  identities = new IdentityProofs({
35554
36616
  ledger: (linkId) => {
@@ -35610,6 +36672,59 @@ var GhostlyNode = class GhostlyNode {
35610
36672
  return this.transport.publishPayload(pubKeyZ32, payload, { background: true });
35611
36673
  }
35612
36674
  });
36675
+ /** The profile's contact link (WISP 8xx): one link that names nobody, its record kept on Pkarr while online. */
36676
+ contactLinks = new ContactLinks({
36677
+ online: () => this.networkOn,
36678
+ emit: () => this.emitState(),
36679
+ publish: (identity, records) => this.transport.publish(identity, records, { background: true }),
36680
+ resolve: async (pubKeyZ32) => (await this.transport.resolve(pubKeyZ32, { background: true }))?.records ?? null,
36681
+ ownInvite: (code) => {
36682
+ const params = decodeInviteCode(code);
36683
+ return !params || inviteOwnership(params, [...this.links.values()].map((l) => l.stored)).kind !== "new";
36684
+ },
36685
+ accept: async (request) => {
36686
+ const params = decodeInviteCode(request.invite);
36687
+ if (!params || inviteOwnership(params, [...this.links.values()].map((l) => l.stored)).kind !== "new") throw new Error("This request's invite is for a chat you already have.");
36688
+ const { linkId } = await this.joinLink({ inviteCode: request.invite });
36689
+ this.renameLink({
36690
+ linkId,
36691
+ label: request.name
36692
+ });
36693
+ await this.storeMessage({
36694
+ linkId,
36695
+ id: `peer_contact_${request.key.slice(0, 32)}`,
36696
+ text: request.text,
36697
+ sender: "peer",
36698
+ timestamp: Math.min(request.issued, request.receivedAt),
36699
+ via: "pkarr"
36700
+ });
36701
+ return linkId;
36702
+ }
36703
+ });
36704
+ /** Requests this profile sends through others' contact links (WISP 8xx). */
36705
+ contactAsks = new ContactAsks({
36706
+ online: () => this.networkOn,
36707
+ emit: () => this.emitState(),
36708
+ publish: (identity, records) => this.transport.publish(identity, records, { background: true }),
36709
+ resolve: async (pubKeyZ32) => (await this.transport.resolve(pubKeyZ32, { background: true }))?.records ?? null,
36710
+ createChat: async ({ label, text }) => {
36711
+ const chat = await this.createLink();
36712
+ if (label) this.renameLink({
36713
+ linkId: chat.linkId,
36714
+ label
36715
+ });
36716
+ await this.storeMessage({
36717
+ linkId: chat.linkId,
36718
+ id: `me_contact_${crypto.randomUUID()}`,
36719
+ text,
36720
+ sender: "me",
36721
+ timestamp: Date.now(),
36722
+ via: "pkarr"
36723
+ });
36724
+ return chat;
36725
+ },
36726
+ ownLink: (linkKeyZ32) => this.contactLinks.owns(linkKeyZ32)
36727
+ });
35613
36728
  /** The Nostr social layer (profile, follows, notes, publication) on top of verified Nostr proofs. */
35614
36729
  nostrSocial = new NostrSocial({
35615
36730
  settings: () => this.settings.nostr,
@@ -35812,13 +36927,15 @@ var GhostlyNode = class GhostlyNode {
35812
36927
  const live = this.links.get(linkId);
35813
36928
  return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
35814
36929
  },
35815
- publish: (identity, records, background, door) => this.groupTransport.publish(identity, records, {
36930
+ publish: (identity, records, background, door, back) => this.groupTransport.publish(identity, records, {
35816
36931
  background,
35817
- door
36932
+ door,
36933
+ back
35818
36934
  }),
35819
- resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
36935
+ resolve: async (pubKeyZ32, background, door, back) => (await this.groupTransport.resolve(pubKeyZ32, {
35820
36936
  background,
35821
- door
36937
+ door,
36938
+ back
35822
36939
  }))?.records ?? null,
35823
36940
  expectPeer: (linkId) => this.links.get(linkId)?.link?.expectPeer(),
35824
36941
  signalIn: (linkId, payload, direct) => !!this.links.get(linkId)?.carried?.accept(payload, direct),
@@ -35832,7 +36949,7 @@ var GhostlyNode = class GhostlyNode {
35832
36949
  else this.closeGroupLink(linkId).catch(() => {});
35833
36950
  },
35834
36951
  edgeNick: (linkId) => this.links.get(linkId)?.presence.nick || void 0,
35835
- storeMessage: (message) => this.storeNewMessage(message),
36952
+ storeMessage: (message, live, place) => this.storeNewMessage(message, live, place),
35836
36953
  completeMessage: (message) => this.completeGroupMessage(message),
35837
36954
  forgetFrame: async (linkId, id) => {
35838
36955
  await db.patchMessage(linkId, id, (stored) => stored.frame ? { frame: void 0 } : null, true);
@@ -35841,6 +36958,7 @@ var GhostlyNode = class GhostlyNode {
35841
36958
  await db.patchMessage(linkId, id, () => ({ frame }), true);
35842
36959
  },
35843
36960
  placeAgain: (linkId, ids) => this.placeAgain(linkId, ids),
36961
+ placeAbove: (linkId, id) => this.placeAbove(linkId, id),
35844
36962
  membersChanged: (groupId) => this.groupMembersChanged(groupId),
35845
36963
  historyGone: (groupId) => this.groupHistoryGone(groupId),
35846
36964
  emit: () => this.emitState(),
@@ -36255,34 +37373,44 @@ var GhostlyNode = class GhostlyNode {
36255
37373
  await this.did.load();
36256
37374
  setOwnDidSource(() => this.did.id);
36257
37375
  this.did.start();
37376
+ await this.contactLinks.load();
37377
+ this.contactLinks.start();
37378
+ await this.contactAsks.load();
37379
+ this.contactAsks.start();
36258
37380
  await this.nostrSocial.load();
36259
37381
  await this.publicProfiles.load();
36260
37382
  if (!this.limitedMode) await this.startWallets();
36261
37383
  const history = /* @__PURE__ */ new Map();
36262
- for (const stored of await db.getLinks()) {
36263
- if (!stored.group) await this.settleHistory(stored.id);
36264
- const messages = stored.group ? [] : await db.getMessages(stored.id);
36265
- const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
37384
+ const links = await db.getLinks();
37385
+ for (const stored of links) history.set(stored.id, []);
37386
+ for (const stored of links) if (stored.group) this.links.set(stored.id, newLiveLink(stored, 0, linkFilesFrom(stored.id, [], [])));
37387
+ for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
37388
+ await this.groups.load(this.limitedMode);
37389
+ this.groupsLoaded = true;
37390
+ this.groupsLoadedNow();
37391
+ this.emitState();
37392
+ for (const stored of links) {
37393
+ if (stored.group) continue;
37394
+ await this.settleHistory(stored.id);
37395
+ const messages = await db.getMessages(stored.id);
37396
+ const storedFiles = await fileStore.listForLink(stored.id);
36266
37397
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
36267
37398
  for (const file of storedFiles) if (file.transfer && file.transfer.state !== "done" && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
36268
37399
  ...file.transfer,
36269
37400
  state: "failed",
36270
37401
  error: "Transfer interrupted. Retry when connected."
36271
37402
  } : file.transfer);
36272
- if (!stored.group) this.fileDesk.restore(stored.id, storedFiles);
37403
+ this.fileDesk.restore(stored.id, storedFiles);
36273
37404
  const files = linkFilesFrom(stored.id, storedFiles, messages);
36274
37405
  this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
36275
- if (!stored.group) this.links.get(stored.id).waitingSends = Promise.resolve(new Set(messages.filter(waitsForLive).map((m) => m.id)));
37406
+ this.links.get(stored.id).waitingSends = Promise.resolve(new Set(messages.filter(waitsForLive).map((m) => m.id)));
36276
37407
  history.set(stored.id, messages);
36277
37408
  for (const message of messages.slice(-20)) this.clockSample(message);
36278
37409
  const note = latestReaction(messages);
36279
37410
  if (note) this.reactionNotes.set(stored.id, note);
36280
- if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
37411
+ if (stored.profile) await this.outboxFor(stored.id).recover();
36281
37412
  }
36282
37413
  this.transfersRestored = true;
36283
- for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
36284
- await this.groups.load(this.limitedMode);
36285
- this.groupsLoaded = true;
36286
37414
  for (const group of this.groups.views()) {
36287
37415
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
36288
37416
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -36674,14 +37802,16 @@ var GhostlyNode = class GhostlyNode {
36674
37802
  /**
36675
37803
  * Says goodbye on every live session (`paired-bye`): the contacts end theirs now and watch for this app to come back,
36676
37804
  * instead of noticing a minute later. Synchronous: an app closing may have no time for anything after it.
37805
+ * `quitting`: the app quits (not a page that may be reloading), so a contact's call still ringing is declined too.
36677
37806
  */
36678
- depart() {
36679
- for (const live of this.links.values()) live.link?.depart();
37807
+ depart(options = {}) {
37808
+ for (const live of this.links.values()) live.link?.depart(options);
36680
37809
  }
36681
37810
  /**
36682
37811
  * Tells every peer we are leaving. Best effort: the browser may already be closing. `quiet`: says nothing to anyone
36683
37812
  * (no goodbye, no departure in the links' packets), as a device another device replaced must (WISP 06 § When a
36684
- * device finds itself superseded: "Nothing is said to contacts and nothing more is published").
37813
+ * device finds itself superseded: "Nothing is said to contacts and nothing more is published"). `quitting`: the app
37814
+ * itself quits (the Desktop app, a CLI daemon), not a web page that may be reloading (`depart`).
36685
37815
  */
36686
37816
  async shutdown(options = {}) {
36687
37817
  const quiet = options.quiet === true || this.gatedOut;
@@ -36692,7 +37822,7 @@ var GhostlyNode = class GhostlyNode {
36692
37822
  this.limitedTimer = null;
36693
37823
  }
36694
37824
  this.appShelf?.stop();
36695
- if (!quiet) this.depart();
37825
+ if (!quiet) this.depart({ quitting: options.quitting === true });
36696
37826
  const linksStopped = Promise.allSettled([...this.links.values()].map(async (live) => {
36697
37827
  live.carried?.stop();
36698
37828
  await Promise.all([live.link?.stop(!quiet), live.caps?.stop()]);
@@ -36726,6 +37856,8 @@ var GhostlyNode = class GhostlyNode {
36726
37856
  this.restoreTimer = null;
36727
37857
  this.identities.stop();
36728
37858
  this.did.stop();
37859
+ this.contactLinks.stop();
37860
+ this.contactAsks.stop();
36729
37861
  this.nostrSocial.stop();
36730
37862
  this.publicProfiles.stop();
36731
37863
  this.publicActivity.clear();
@@ -36763,6 +37895,7 @@ var GhostlyNode = class GhostlyNode {
36763
37895
  const withdraw = Promise.allSettled([
36764
37896
  this.hold.withdrawAll(),
36765
37897
  this.did.deactivate(),
37898
+ this.contactLinks.withdraw(),
36766
37899
  this.identities.revokeAll()
36767
37900
  ]);
36768
37901
  await Promise.race([withdraw, new Promise((resolve) => setTimeout(resolve, CLEAR_WITHDRAW_MS))]);
@@ -36891,6 +38024,9 @@ var GhostlyNode = class GhostlyNode {
36891
38024
  payments: this.desk.views(),
36892
38025
  identityProofs: this.identities.views(),
36893
38026
  did: this.did.view(),
38027
+ contactLink: this.contactLinks.view(),
38028
+ contactRequests: this.contactLinks.requests(),
38029
+ contactAsks: this.contactAsks.views(),
36894
38030
  nostr: this.nostrSocial.state(),
36895
38031
  edges: [...[...this.links.values()].filter((live) => live.stored.group && !live.stored.groupEntry).map((live) => this.viewOf(live)), ...this.communityPayViews(groups)],
36896
38032
  groups: groups.map((group) => ({
@@ -37202,9 +38338,42 @@ var GhostlyNode = class GhostlyNode {
37202
38338
  recheckIdentityProof(params) {
37203
38339
  return this.identities.recheck(params);
37204
38340
  }
38341
+ newX401HolderKey() {
38342
+ return this.x401.newKey();
38343
+ }
38344
+ addX401Credential(params) {
38345
+ return this.x401.add(params);
38346
+ }
38347
+ x401Credentials(params = {}) {
38348
+ return this.x401.list(params);
38349
+ }
38350
+ removeX401Credential(params) {
38351
+ return this.x401.remove(params);
38352
+ }
37205
38353
  setDidListed(params) {
37206
38354
  return this.did.setListed(params);
37207
38355
  }
38356
+ /** A new contact link for the profile; the one before is turned off. */
38357
+ createContactLink() {
38358
+ return this.contactLinks.create();
38359
+ }
38360
+ turnOffContactLink() {
38361
+ return this.contactLinks.turnOff();
38362
+ }
38363
+ /** The contact link was shown or copied, or Requests opened: look for requests now and often for a while. */
38364
+ contactLinkShown() {
38365
+ this.contactLinks.shown();
38366
+ }
38367
+ acceptContactRequest({ id }) {
38368
+ return this.contactLinks.accept(id);
38369
+ }
38370
+ declineContactRequest({ id }) {
38371
+ return this.contactLinks.decline(id);
38372
+ }
38373
+ /** Sends a request through someone's contact link; the chat made for it. */
38374
+ askContactLink(params) {
38375
+ return this.contactAsks.ask(params);
38376
+ }
37208
38377
  lookupIdentityDisplay(params) {
37209
38378
  const r = this.links.get(params.linkId)?.stored.identities?.received.find((x) => x.id === params.id);
37210
38379
  if (r?.binding.provider === "nostr") return this.nostrSocial.loadContact({
@@ -37536,6 +38705,8 @@ var GhostlyNode = class GhostlyNode {
37536
38705
  groupHistoryGone(groupId) {
37537
38706
  const linkId = `group:${groupId}`;
37538
38707
  this.newestAt.delete(linkId);
38708
+ this.lateAt.delete(linkId);
38709
+ for (const clock of this.clockLeads.keys()) if (clock.startsWith(`${linkId} `)) this.clockLeads.delete(clock);
37539
38710
  this.placedAt.delete(linkId);
37540
38711
  this.reactionNotes.delete(linkId);
37541
38712
  this.reactionNotesBefore.delete(linkId);
@@ -37699,6 +38870,7 @@ var GhostlyNode = class GhostlyNode {
37699
38870
  this.nostrSocial.forgetLink(linkId);
37700
38871
  this.hold.forgetLink(linkId);
37701
38872
  this.fileDesk.drop(linkId);
38873
+ this.contactAsks.forgetChat(linkId).catch(() => {});
37702
38874
  this.wakeLimiter.reset(linkId);
37703
38875
  this.callWakeLimiter.reset(linkId);
37704
38876
  if (live.stored.wakeToken) this.rotateWake().then(() => this.emitState());
@@ -38279,7 +39451,7 @@ var GhostlyNode = class GhostlyNode {
38279
39451
  if (!queue) {
38280
39452
  queue = new EditQueue({
38281
39453
  read: () => db.getMessages(linkId),
38282
- row: (id) => db.getMessage(linkId, id),
39454
+ rows: (ids) => db.getMessagesById(linkId, ids),
38283
39455
  ready: () => {
38284
39456
  const live = this.links.get(linkId), link = live?.link;
38285
39457
  return !!link && (link.supportsEdits || link.textDelivery === "dht" && this.takesDhtEdits(live));
@@ -39684,7 +40856,7 @@ var GhostlyNode = class GhostlyNode {
39684
40856
  * as a new message of mine, in the order they were written, carrying one more hop than it had and nothing of who wrote
39685
40857
  * it or where. A text goes as written; a file from the bytes on this device (a copy per chat, nothing fetched again),
39686
40858
  * through the chat's own path, so a large one still waits for the contact's consent. A group takes texts only. What one
39687
- * chat refuses does not stop the others: each says what it got.
40859
+ * chat refuses does not stop the others, nor the rest of its own: each says what it got, and which of them (`sent`).
39688
40860
  */
39689
40861
  async forwardMessages(params) {
39690
40862
  const { linkId } = params;
@@ -39695,7 +40867,7 @@ var GhostlyNode = class GhostlyNode {
39695
40867
  if (ids.length > 30) throw new Error(`Forward at most 30 messages at once`);
39696
40868
  if (!targets.length) throw new Error("No chat to forward to");
39697
40869
  if (targets.length > 5) throw new Error(`Forward to at most 5 chats at once`);
39698
- if (linkId.startsWith("group:") ? !this.membership(linkId.slice(6)) : this.links.get(linkId)?.stored.group !== void 0 || !this.links.has(linkId)) throw new Error("Chat not found");
40870
+ if (linkId.startsWith("group:") ? !this.groups.views().some((g) => g.id === linkId.slice(6)) : this.links.get(linkId)?.stored.group !== void 0 || !this.links.has(linkId)) throw new Error("Chat not found");
39699
40871
  const chosen = await Promise.all(ids.map((id) => db.getMessage(linkId, id)));
39700
40872
  if (chosen.some((m) => !m)) throw new Error("That message is not in this chat");
39701
40873
  const messages = chosen.sort((a, b) => a.timestamp - b.timestamp);
@@ -39710,6 +40882,7 @@ var GhostlyNode = class GhostlyNode {
39710
40882
  const result = {
39711
40883
  to,
39712
40884
  messageIds: [],
40885
+ sent: [],
39713
40886
  error: null
39714
40887
  };
39715
40888
  results.push(result);
@@ -39731,11 +40904,20 @@ var GhostlyNode = class GhostlyNode {
39731
40904
  text: message.text,
39732
40905
  timestamp: now()
39733
40906
  });
39734
- if (sent.error) note(sent.error);
39735
- else if (sent.messageId) result.messageIds.push(sent.messageId);
40907
+ if (sent.error) {
40908
+ note(sent.error);
40909
+ continue;
40910
+ }
40911
+ if (sent.messageId) result.messageIds.push(sent.messageId);
40912
+ result.sent.push(message.id);
39736
40913
  continue;
39737
40914
  }
39738
40915
  if (groupId) {
40916
+ const check = this.groups.fileCheck(groupId);
40917
+ if (check.error) {
40918
+ note(check.error);
40919
+ continue;
40920
+ }
39739
40921
  const original = message.file, file = {
39740
40922
  id: groupFileId(groupId, "out"),
39741
40923
  name: original.name,
@@ -39759,7 +40941,10 @@ var GhostlyNode = class GhostlyNode {
39759
40941
  if (sent.error) {
39760
40942
  await removeStored(file.id).catch(() => {});
39761
40943
  note(sent.error);
39762
- } else if (sent.messageId) result.messageIds.push(sent.messageId);
40944
+ continue;
40945
+ }
40946
+ if (sent.messageId) result.messageIds.push(sent.messageId);
40947
+ result.sent.push(message.id);
39763
40948
  continue;
39764
40949
  }
39765
40950
  if (!live.link) {
@@ -39795,8 +40980,9 @@ var GhostlyNode = class GhostlyNode {
39795
40980
  throw error;
39796
40981
  }
39797
40982
  result.messageIds.push(`me_${timestamp}`);
40983
+ result.sent.push(message.id);
39798
40984
  } catch (error) {
39799
- note(error instanceof Error ? error.message : String(error));
40985
+ note(forwardError(error));
39800
40986
  }
39801
40987
  }
39802
40988
  }
@@ -40655,12 +41841,18 @@ var GhostlyNode = class GhostlyNode {
40655
41841
  const n = this.net(network);
40656
41842
  return via === "cashu" ? this.wallet.quoteInvoice(invoice, n) : this.lightningCard(n, card).then((c) => c.quote(invoice));
40657
41843
  }
40658
- /** `confirmedReal`: the person confirmed a Mainnet payment as real money; without it one is refused. */
40659
- async walletPayQuote({ quote, mint, note, confirmedReal }) {
41844
+ /**
41845
+ * `confirmedReal`: the person confirmed a Mainnet payment as real money; without it one is refused. `linkId` +
41846
+ * `invoice`: the chat whose message held the invoice. Paid on Testnet, its contact is told (`sayInvoicePaid`): a test
41847
+ * mint's invoice counts only on the payer's word.
41848
+ */
41849
+ async walletPayQuote({ quote, mint, note, confirmedReal, linkId, invoice }) {
40660
41850
  const network = WALLET_NETWORKS.find((n) => this.lightnings[n].withQuote(quote));
40661
41851
  const lightning = network && this.lightnings[network].withQuote(quote);
40662
41852
  assertConfirmedReal(network ?? mintNetwork(mint), confirmedReal);
40663
- return { paid: lightning ? await lightning.pay(quote, { note }) : await this.wallet.payQuote(quote, mint, note) };
41853
+ const paid = lightning ? await lightning.pay(quote, { note }) : await this.wallet.payQuote(quote, mint, note);
41854
+ if (paid && linkId && invoice && (network ?? mintNetwork(mint)) === "testnet") this.desk.sayInvoicePaid(linkId, invoice);
41855
+ return { paid };
40664
41856
  }
40665
41857
  /** A Lightning address or LNURL (LUD-16, LUD-06): resolved here, in the engine, so every platform fetches the same way. */
40666
41858
  lnurlResolve({ text, network }) {
@@ -40789,8 +41981,8 @@ var GhostlyNode = class GhostlyNode {
40789
41981
  bitcoinRefresh(params) {
40790
41982
  return this.bitcoins[this.net(params?.network)].sources.refresh();
40791
41983
  }
40792
- walletInspectCashu({ text }) {
40793
- return { inspection: this.wallet.inspect(text) };
41984
+ async walletInspectCashu({ text }) {
41985
+ return { inspection: await this.wallet.inspect(text) };
40794
41986
  }
40795
41987
  async walletReceiveToken({ token }) {
40796
41988
  const { amount } = await this.wallet.receiveToken(token.trim());
@@ -42333,7 +43525,8 @@ var GhostlyNode = class GhostlyNode {
42333
43525
  carry: (payload) => this.groups.carrySignal(group, peer, payload),
42334
43526
  open: () => !!live.link?.isDataLinkOpen,
42335
43527
  look: () => live.link?.look(),
42336
- direct: (payload) => this.groups.directSignal(group, peer, linkId, payload)
43528
+ direct: (payload) => this.groups.directSignal(group, peer, linkId, payload),
43529
+ after: live.carried?.lastDated()
42337
43530
  });
42338
43531
  const waiting = live.carried && this.groups.takeSignal(group, peer);
42339
43532
  if (waiting) live.carried.accept(waiting);
@@ -42598,6 +43791,7 @@ var GhostlyNode = class GhostlyNode {
42598
43791
  } : void 0;
42599
43792
  const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
42600
43793
  let dhtShown;
43794
+ let wasTaken = false;
42601
43795
  live.link = new GhostLink({
42602
43796
  paymentMethods: stored.paymentMethods,
42603
43797
  paymentNetworks: this.chatNetworks(stored),
@@ -42687,6 +43881,7 @@ var GhostlyNode = class GhostlyNode {
42687
43881
  throw error;
42688
43882
  }
42689
43883
  if (!previous.pairedPeerKey) {
43884
+ if (!live.stored.group && live.stored.deliveryMode !== "dht") this.ensureNativeEndpoints(stored.id);
42690
43885
  live.caps?.update().catch(() => {});
42691
43886
  live.caps?.refresh(true);
42692
43887
  }
@@ -42726,6 +43921,11 @@ var GhostlyNode = class GhostlyNode {
42726
43921
  onDhtDelivery: (view) => {
42727
43922
  if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
42728
43923
  this.observeTransport(linkId);
43924
+ const taken = !!live.link?.takenFromUs;
43925
+ if (taken !== wasTaken) {
43926
+ wasTaken = taken;
43927
+ if (!taken && live.stored.deliveryMode !== "dht" && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
43928
+ }
42729
43929
  const shown = JSON.stringify(view);
42730
43930
  if (shown !== dhtShown) {
42731
43931
  dhtShown = shown;
@@ -43093,7 +44293,7 @@ var GhostlyNode = class GhostlyNode {
43093
44293
  get nativeFactories() {
43094
44294
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
43095
44295
  return {
43096
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-De5FT7Am.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
44296
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-13aQ4KOf.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
43097
44297
  ...this.options.nativeTransports,
43098
44298
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
43099
44299
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -43223,7 +44423,7 @@ var GhostlyNode = class GhostlyNode {
43223
44423
  */
43224
44424
  async startNativeEndpoint(linkId, expected, key, factory, inUse) {
43225
44425
  const live = this.links.get(linkId), link = live?.link;
43226
- if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
44426
+ if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected || link.takenFromUs) return false;
43227
44427
  const group = !!live.stored.group;
43228
44428
  if (group && !this.keepsGroupNative(live.stored)) return false;
43229
44429
  if (link.availableTransports.includes(key)) return false;
@@ -43282,7 +44482,7 @@ var GhostlyNode = class GhostlyNode {
43282
44482
  starting.then((late) => late.close(), () => {});
43283
44483
  return false;
43284
44484
  }
43285
- if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
44485
+ if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId) || link.takenFromUs) {
43286
44486
  await endpoint.close();
43287
44487
  return false;
43288
44488
  }
@@ -43463,8 +44663,9 @@ var GhostlyNode = class GhostlyNode {
43463
44663
  * ahead of this clock nor above a row placed here since the app started. The admin's clock picks that time: two
43464
44664
  * minutes ahead, "X joined" sat under everything said in the next two minutes; behind, it went above what the
43465
44665
  * members had just read. Read late with nothing newer here (a member back after a while), it keeps its moment.
44666
+ * `place`: a received row's place given already (`placeAbove`): it takes it, and the next row still goes past the last.
43466
44667
  */
43467
- placed(message) {
44668
+ placed(message, place) {
43468
44669
  const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
43469
44670
  if (message.event) {
43470
44671
  if (message.sender !== "peer") return message;
@@ -43479,10 +44680,10 @@ var GhostlyNode = class GhostlyNode {
43479
44680
  const sentAt = claimedTime(message.timestamp);
43480
44681
  message = {
43481
44682
  ...message,
43482
- timestamp: arrivalKey(now, last),
44683
+ timestamp: place ?? arrivalKey(now, last),
43483
44684
  ...sentAt !== void 0 && { sentAt }
43484
44685
  };
43485
- this.placedAt.set(message.linkId, message.timestamp);
44686
+ if (place === void 0) this.placedAt.set(message.linkId, message.timestamp);
43486
44687
  } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
43487
44688
  return message;
43488
44689
  }
@@ -43504,11 +44705,32 @@ var GhostlyNode = class GhostlyNode {
43504
44705
  }));
43505
44706
  if (moved.length) await this.messagesChanged(linkId, moved.map((m) => m.id));
43506
44707
  }
44708
+ /**
44709
+ * A place just above this row of its history, below the one before it: where a group member's message that comes
44710
+ * after a later one of theirs is stored, so nothing has to move once the rest comes (WISP 400, requirement 10). At
44711
+ * most `AUTHOR_ORDER_ROOM` below the row, so the next older page finds room above it too; none when the two rows are
44712
+ * less than two milliseconds apart (it goes at the end, and is placed again).
44713
+ */
44714
+ async placeAbove(linkId, id) {
44715
+ const row = await db.getMessage(linkId, id);
44716
+ if (!row) return void 0;
44717
+ const floor = (await db.getMessagePage(linkId, {
44718
+ limit: 1,
44719
+ before: {
44720
+ timestamp: row.timestamp,
44721
+ id: row.id
44722
+ }
44723
+ })).messages[0]?.timestamp ?? -Infinity;
44724
+ return row.timestamp - floor < 2 ? void 0 : Math.max(floor + 1, row.timestamp - 64);
44725
+ }
43507
44726
  async storeMessage(message) {
43508
44727
  await this.storeNewMessage(message);
43509
44728
  }
43510
- /** Stores a message; false when it was there already (or deleted here): nothing new came. */
43511
- async storeNewMessage(message) {
44729
+ /**
44730
+ * Stores a message; false when it was there already (or deleted here): nothing new came. `straight`: a group's,
44731
+ * straight from its author (`clockSample`). `place`: as `placed`.
44732
+ */
44733
+ async storeNewMessage(message, straight = false, place) {
43512
44734
  const edge = this.links.get(message.linkId)?.stored;
43513
44735
  if (edge?.group && edge.groupPeer && !edge.groupEntry) message = {
43514
44736
  ...message,
@@ -43523,7 +44745,7 @@ var GhostlyNode = class GhostlyNode {
43523
44745
  id: `${message.linkId}:${message.id}`,
43524
44746
  ...message.sender === "peer" ? { member: pay.member } : {}
43525
44747
  };
43526
- message = this.placed(message);
44748
+ message = this.placed(message, place);
43527
44749
  const live = this.links.get(message.linkId);
43528
44750
  if (live?.stored.deletedIds?.includes(message.id)) return false;
43529
44751
  message = await this.resolveReply(message);
@@ -43553,9 +44775,9 @@ var GhostlyNode = class GhostlyNode {
43553
44775
  }
43554
44776
  if (!await db.addMessage(message)) return false;
43555
44777
  if (live && waitsForLive(message)) live.waitingSends?.then((known) => known.add(message.id), () => {});
43556
- this.clockSample(message);
44778
+ this.clockSample(message, straight);
43557
44779
  const newest = this.newestAt.get(message.linkId);
43558
- if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
44780
+ if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(this.clockOf(message)))));
43559
44781
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
43560
44782
  if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
43561
44783
  const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
@@ -43822,8 +45044,8 @@ var EngineServer = class {
43822
45044
  this.clients.delete(client);
43823
45045
  });
43824
45046
  }
43825
- stop() {
43826
- return this.node.shutdown();
45047
+ stop(options = {}) {
45048
+ return this.node.shutdown({ quitting: options.quitting === true });
43827
45049
  }
43828
45050
  detach(client) {
43829
45051
  this.clients.delete(client);
@@ -43848,13 +45070,13 @@ var EngineServer = class {
43848
45070
  params = { on: this.onCall.size > 0 };
43849
45071
  }
43850
45072
  try {
43851
- await this.ready;
45073
+ await (readsGroupHistory(request) ? Promise.race([this.ready, this.node.groupsRead]) : this.ready);
43852
45074
  const method = this.node[request.method];
43853
45075
  if (typeof method !== "function") throw new Error(`Unknown method: ${request.method}`);
43854
45076
  if (this.node.limited && !LIMITED_MODE_METHODS.has(String(request.method))) throw new Error(LIMITED_MODE_ERROR);
43855
45077
  response.result = await method.call(this.node, params);
43856
45078
  } catch (error) {
43857
- response.error = error instanceof Error ? error.message : String(error);
45079
+ response.error = errorLine(error);
43858
45080
  }
43859
45081
  try {
43860
45082
  client.post(response);
@@ -43931,6 +45153,12 @@ var EngineServer = class {
43931
45153
  for (const client of [...this.clients]) this.post(client, event);
43932
45154
  }
43933
45155
  };
45156
+ /** Whether a call only reads a group's stored history (`GhostlyNode.groupsRead`). */
45157
+ function readsGroupHistory(request) {
45158
+ if (request.method === "groupMessages") return true;
45159
+ const linkId = request.params?.linkId;
45160
+ return request.method === "messagePage" && typeof linkId === "string" && linkId.startsWith("group:");
45161
+ }
43934
45162
  /** A call signal's kind (`o` for an offer), read as the client reads it; checked in full before it is replayed. */
43935
45163
  function signalKind(signal) {
43936
45164
  try {
@@ -43942,4 +45170,4 @@ function signalKind(signal) {
43942
45170
  //#endregion
43943
45171
  export { EngineServer };
43944
45172
 
43945
- //# sourceMappingURL=server-C-EkLCCt.js.map
45173
+ //# sourceMappingURL=server-Cd1dLOB8.js.map