fedipod-server 0.21.0 → 0.28.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 (332) hide show
  1. package/README.md +5 -4
  2. package/dist/claims.d.ts +3 -1
  3. package/dist/claims.js +20 -7
  4. package/dist/components/context.jsonld +6 -0
  5. package/dist/handler.d.ts +29 -1
  6. package/dist/handler.js +89 -4
  7. package/dist/handler.jsonld +36 -1
  8. package/lib/client/c2s.mjs +100 -3
  9. package/lib/client/masto/accounts.mjs +44 -4
  10. package/lib/client/masto/body.mjs +90 -3
  11. package/lib/client/masto/index.mjs +5 -1
  12. package/lib/client/masto/instance.mjs +11 -3
  13. package/lib/client/masto/media.mjs +3 -57
  14. package/lib/client/masto/oauth.mjs +66 -9
  15. package/lib/client/masto/render.mjs +62 -5
  16. package/lib/client/masto/statuses.mjs +25 -1
  17. package/lib/client/masto/timelines.mjs +128 -6
  18. package/lib/connections/tagfeed.mjs +42 -5
  19. package/lib/core/as2.mjs +36 -5
  20. package/lib/core/contexts/index.mjs +14 -0
  21. package/lib/core/contexts/litepub-0.1.json +60 -0
  22. package/lib/core/contexts/map.json +3 -1
  23. package/lib/core/contexts/quotes.json +18 -0
  24. package/lib/core/graphview.mjs +41 -3
  25. package/lib/core/intake/activities.mjs +136 -2
  26. package/lib/core/intake/activity.mjs +79 -1
  27. package/lib/core/intake/group.mjs +37 -3
  28. package/lib/core/intake/index.mjs +32 -2
  29. package/lib/core/intake/notes.mjs +60 -3
  30. package/lib/core/lease.mjs +12 -2
  31. package/lib/core/profile-page.mjs +104 -0
  32. package/lib/core/publisher/collections.mjs +6 -1
  33. package/lib/core/publisher/index.mjs +35 -17
  34. package/lib/core/publisher/notes.mjs +104 -3
  35. package/lib/core/shapes/activitystreams.ttl +28 -0
  36. package/lib/core/shapes/index.mjs +26 -1
  37. package/lib/core/wire-quotes.mjs +71 -0
  38. package/lib/core/wire.mjs +59 -103
  39. package/lib/device/admin/routes/lifecycle.mjs +9 -0
  40. package/lib/device/admin/static.mjs +4 -1
  41. package/lib/device/admin/surface.mjs +26 -2
  42. package/lib/device/certs.mjs +18 -2
  43. package/lib/device/cli/commands/setup.mjs +6 -2
  44. package/lib/device/cli/commands/state.mjs +3 -1
  45. package/lib/device/gateway-move.mjs +58 -0
  46. package/lib/device/migrate.mjs +3 -1
  47. package/lib/device/setup.mjs +67 -7
  48. package/lib/gateway/front-core.mjs +307 -36
  49. package/lib/gateway/gateway-core.mjs +15 -6
  50. package/lib/gateway/notices.mjs +84 -0
  51. package/lib/gateway/quiet.mjs +184 -0
  52. package/lib/pod/root.mjs +6 -3
  53. package/lib/pod/state.mjs +8 -7
  54. package/lib/pod/transport.mjs +5 -1
  55. package/lib/session/README.md +123 -0
  56. package/lib/session/demo.html +94 -0
  57. package/lib/session/fedi-account.mjs +346 -0
  58. package/lib/session/fedi-login.mjs +123 -0
  59. package/lib/session/oidc-session.mjs +233 -0
  60. package/lib/session/package.json +41 -0
  61. package/package.json +1 -1
  62. package/run-agent.mjs +28 -0
  63. package/vendor/gate.cjs +6 -3
  64. package/web/admin/bar.css +66 -0
  65. package/web/admin/bar.js +38 -0
  66. package/web/admin/client/index.html +31 -3
  67. package/web/admin/client-pick.js +79 -0
  68. package/web/admin/gateway.js +22 -0
  69. package/web/admin/index.html +54 -13
  70. package/web/admin/notices-bar.js +90 -0
  71. package/web/admin/oauth-signin.mjs +37 -0
  72. package/web/admin/record.js +13 -1
  73. package/web/admin/setup/index.html +31 -3
  74. package/web/admin/tokens.css +15 -3
  75. package/web/admin/upkeep.js +4 -5
  76. package/web/app/README.md +14 -10
  77. package/web/app/admin-facade.mjs +51 -12
  78. package/web/app/agent.mjs +157 -19
  79. package/web/app/authorize_interaction.html +56 -0
  80. package/web/app/authorize_interaction.js +69 -0
  81. package/web/app/boot.mjs +233 -95
  82. package/web/app/dist/boot.js +477 -569
  83. package/web/app/dist/boot.js.map +4 -4
  84. package/web/app/dist/sw.js +1861 -532
  85. package/web/app/dist/sw.js.map +4 -4
  86. package/web/app/gateway-move.mjs +60 -0
  87. package/web/app/index.html +40 -49
  88. package/web/app/keys-browser.mjs +12 -15
  89. package/web/app/keystore.mjs +19 -36
  90. package/web/app/oidc-session.mjs +5 -188
  91. package/web/app/signup.mjs +172 -132
  92. package/web/app/sw-src.mjs +1 -1
  93. package/web/app/update.js +66 -0
  94. package/web/front/admin.html +5 -3
  95. package/web/front/new-account.html +2 -2
  96. package/web/front/notices.html +69 -0
  97. package/web/front/notices.js +107 -0
  98. package/web/front/run.html +15 -8
  99. package/web/front/run.js +124 -36
  100. package/web/icons/forum.svg +11 -0
  101. package/web/icons/gateway.svg +14 -0
  102. package/web/icons/local.svg +11 -0
  103. package/web/app/index.html~ +0 -152
  104. package/web/app/site/_headers +0 -9
  105. package/web/app/site/_redirects +0 -22
  106. package/web/app/site/admin/actors.js +0 -145
  107. package/web/app/site/admin/bar.css +0 -60
  108. package/web/app/site/admin/bar.js +0 -61
  109. package/web/app/site/admin/client/client.js +0 -66
  110. package/web/app/site/admin/client/index.html +0 -43
  111. package/web/app/site/admin/common.js +0 -23
  112. package/web/app/site/admin/connections.js +0 -112
  113. package/web/app/site/admin/gateway.js +0 -111
  114. package/web/app/site/admin/group.js +0 -258
  115. package/web/app/site/admin/index.html +0 -564
  116. package/web/app/site/admin/record.js +0 -378
  117. package/web/app/site/admin/setup/index.html +0 -215
  118. package/web/app/site/admin/setup/setup.js +0 -326
  119. package/web/app/site/admin/tokens.css +0 -56
  120. package/web/app/site/admin/upkeep.js +0 -178
  121. package/web/app/site/admin/window.css +0 -55
  122. package/web/app/site/admin/window.js +0 -136
  123. package/web/app/site/app/404.html +0 -40
  124. package/web/app/site/app/_headers +0 -2
  125. package/web/app/site/app/apple-touch-icon.png +0 -0
  126. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +0 -2
  127. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +0 -1
  128. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +0 -183
  129. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +0 -1
  130. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  131. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +0 -2
  132. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +0 -1
  133. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  134. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  135. package/web/app/site/app/assets/chunk-aKtaBQYM.js +0 -1
  136. package/web/app/site/app/assets/compose-CFEx7wb4.js +0 -2
  137. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +0 -1
  138. package/web/app/site/app/assets/compose-ZJN5xSb4.js +0 -35
  139. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +0 -1
  140. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +0 -52
  141. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +0 -1
  142. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +0 -2
  143. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +0 -1
  144. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  145. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  146. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  147. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  148. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +0 -1
  149. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +0 -1
  150. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +0 -1
  151. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +0 -1
  152. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +0 -1
  153. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +0 -1
  154. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +0 -1
  155. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +0 -1
  156. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +0 -1
  157. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +0 -1
  158. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +0 -1
  159. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +0 -1
  160. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +0 -1
  161. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +0 -1
  162. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +0 -1
  163. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +0 -1
  164. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +0 -1
  165. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +0 -1
  166. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +0 -1
  167. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +0 -1
  168. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +0 -1
  169. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +0 -1
  170. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +0 -1
  171. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +0 -1
  172. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +0 -1
  173. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +0 -1
  174. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +0 -1
  175. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +0 -1
  176. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +0 -1
  177. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +0 -1
  178. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +0 -1
  179. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +0 -1
  180. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +0 -1
  181. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +0 -1
  182. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +0 -1
  183. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +0 -1
  184. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +0 -1
  185. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +0 -1
  186. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +0 -1
  187. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +0 -1
  188. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +0 -1
  189. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +0 -1
  190. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +0 -1
  191. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +0 -1
  192. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +0 -1
  193. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +0 -1
  194. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +0 -1
  195. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +0 -1
  196. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +0 -1
  197. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +0 -1
  198. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +0 -1
  199. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +0 -1
  200. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +0 -1
  201. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +0 -1
  202. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +0 -1
  203. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +0 -1
  204. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +0 -1
  205. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +0 -1
  206. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +0 -1
  207. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +0 -1
  208. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +0 -1
  209. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +0 -1
  210. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +0 -1
  211. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +0 -1
  212. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +0 -1
  213. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +0 -1
  214. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +0 -1
  215. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +0 -1
  216. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +0 -1
  217. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +0 -1
  218. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +0 -1
  219. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +0 -1
  220. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +0 -1
  221. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +0 -1
  222. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +0 -1
  223. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +0 -1
  224. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +0 -1
  225. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +0 -1
  226. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +0 -1
  227. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +0 -1
  228. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +0 -1
  229. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +0 -1
  230. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +0 -1
  231. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +0 -1
  232. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +0 -1
  233. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +0 -1
  234. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +0 -1
  235. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +0 -1
  236. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +0 -1
  237. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +0 -1
  238. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +0 -1
  239. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +0 -1
  240. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +0 -1
  241. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +0 -1
  242. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +0 -1
  243. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +0 -1
  244. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +0 -1
  245. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +0 -1
  246. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +0 -1
  247. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +0 -1
  248. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +0 -1
  249. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +0 -1
  250. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +0 -1
  251. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +0 -1
  252. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +0 -1
  253. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +0 -1
  254. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +0 -1
  255. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +0 -1
  256. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +0 -1
  257. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +0 -1
  258. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +0 -1
  259. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +0 -1
  260. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +0 -1
  261. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +0 -1
  262. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +0 -1
  263. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +0 -1
  264. package/web/app/site/app/assets/instances-BNPptnn-.json +0 -333
  265. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +0 -1
  266. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +0 -1
  267. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +0 -1
  268. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +0 -1
  269. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +0 -1
  270. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +0 -1
  271. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +0 -1
  272. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +0 -1
  273. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +0 -1
  274. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +0 -1
  275. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +0 -1
  276. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +0 -1
  277. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +0 -1
  278. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +0 -1
  279. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +0 -1
  280. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +0 -1
  281. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +0 -1
  282. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +0 -1
  283. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +0 -1
  284. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +0 -1
  285. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +0 -1
  286. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +0 -1
  287. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +0 -1
  288. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +0 -1
  289. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +0 -1
  290. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +0 -1
  291. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +0 -1
  292. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +0 -1
  293. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +0 -1
  294. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +0 -1
  295. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +0 -1
  296. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +0 -1
  297. package/web/app/site/app/assets/main-BdqNbG-a.js +0 -26
  298. package/web/app/site/app/assets/main-BdqNbG-a.js.map +0 -1
  299. package/web/app/site/app/assets/mock-home-DGhR__He.js +0 -2
  300. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +0 -1
  301. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  302. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  303. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  304. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +0 -2
  305. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +0 -1
  306. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +0 -20
  307. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +0 -1
  308. package/web/app/site/app/assets/style-B1iLPTPR.css +0 -2
  309. package/web/app/site/app/assets/temml-DuyIQMei.js +0 -97
  310. package/web/app/site/app/assets/temml-DuyIQMei.js.map +0 -1
  311. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +0 -2
  312. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +0 -1
  313. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  314. package/web/app/site/app/assets/year-in-posts-DJv_star.js +0 -3
  315. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +0 -1
  316. package/web/app/site/app/compose/index.html +0 -20
  317. package/web/app/site/app/favicon.ico +0 -0
  318. package/web/app/site/app/index.html +0 -73
  319. package/web/app/site/app/logo-192.png +0 -0
  320. package/web/app/site/app/logo-512.png +0 -0
  321. package/web/app/site/app/logo-badge-72.png +0 -0
  322. package/web/app/site/app/logo-maskable-512.png +0 -0
  323. package/web/app/site/app/logo-monochrome-512.png +0 -0
  324. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  325. package/web/app/site/app/manifest.webmanifest +0 -1
  326. package/web/app/site/app/og-image-2.jpg +0 -0
  327. package/web/app/site/app/og-image.png +0 -0
  328. package/web/app/site/app/robots.txt +0 -2
  329. package/web/app/site/app/version.json +0 -1
  330. package/web/app/site/boot.js +0 -34277
  331. package/web/app/site/index.html +0 -251
  332. package/web/app/site/sw.js +0 -71331
@@ -0,0 +1,233 @@
1
+ // oidc-session.mjs — Solid-OIDC login for a browser page, with a secure,
2
+ // durable, worker-visible session.
3
+ //
4
+ // The redirect handshake (authorization-code + PKCE) runs in the page. The
5
+ // session it yields — a DPoP keypair whose private half is NON-EXTRACTABLE, plus
6
+ // the refresh token, the token endpoint and the WebID — is kept in IndexedDB.
7
+ // IndexedDB survives a browser restart and is readable by a service worker,
8
+ // and a non-extractable key can sign but never be read out, even by injected
9
+ // script.
10
+ //
11
+ // import { solidOidcSession } from 'solid-oidc-session/oidc-session.mjs';
12
+ // const oidc = solidOidcSession({ dbName: 'my-app-oidc', clientName: 'My app' });
13
+ //
14
+ // Page: const { authorizationUrl } = await oidc.beginLogin({ issuer, redirectUri });
15
+ // location.href = authorizationUrl; // ... redirect ...
16
+ // await oidc.completeLogin({ currentUrl: location.href }); // on return
17
+ // Anywhere (page or worker, SAME dbName):
18
+ // const session = await oidc.getSession(); // { webId, issuer, fetch, refresh, signOut } | null
19
+ //
20
+ // No imports, no Node built-ins, nothing named after the application that
21
+ // uses it: IndexedDB, WebCrypto and fetch are all it needs.
22
+
23
+ const STORE = 'session';
24
+ const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
25
+ const enc = (o) => b64u(new TextEncoder().encode(JSON.stringify(o)));
26
+ const sha256 = (s) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
27
+ const rand = (n = 32) => b64u(crypto.getRandomValues(new Uint8Array(n)));
28
+
29
+ async function dpopKey() { return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); }
30
+ async function publicJwk(pair) { const j = await crypto.subtle.exportKey('jwk', pair.publicKey); return { kty: j.kty, crv: j.crv, x: j.x, y: j.y }; }
31
+ async function dpopProof(pair, jwk, htm, htu, ath) {
32
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk };
33
+ const payload = { htm, htu: htu.split('#')[0], jti: crypto.randomUUID(), iat: Math.floor(Date.now() / 1000), ...(ath ? { ath } : {}) };
34
+ const data = `${enc(header)}.${enc(payload)}`;
35
+ const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, new TextEncoder().encode(data));
36
+ return `${data}.${b64u(sig)}`;
37
+ }
38
+ const jwtPayload = (jwt) => { try { return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)))); } catch { return {}; } };
39
+
40
+ async function discover(issuer) {
41
+ const res = await fetch(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
42
+ if (!res.ok) throw new Error(`OIDC discovery failed at ${issuer} (HTTP ${res.status})`);
43
+ return res.json();
44
+ }
45
+ async function registerClient(cfg, redirectUri, clientName) {
46
+ const res = await fetch(cfg.registration_endpoint, {
47
+ method: 'POST', headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify({ client_name: clientName, redirect_uris: [redirectUri], token_endpoint_auth_method: 'none',
49
+ grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', scope: 'openid webid offline_access' }),
50
+ });
51
+ if (!res.ok) throw new Error(`client registration failed (HTTP ${res.status})`);
52
+ return (await res.json()).client_id;
53
+ }
54
+
55
+ /**
56
+ * One session store. `dbName` is the IndexedDB database the session lives in:
57
+ * a page and its service worker must pass the same name to see the same
58
+ * sign-in, and two apps on one origin pass different names to keep theirs
59
+ * apart. `clientName` is what the pod's login screen shows the person.
60
+ */
61
+ export function solidOidcSession({ dbName = 'solid-oidc-session', clientName = 'Solid app' } = {}) {
62
+ function idb() {
63
+ return new Promise((res, rej) => {
64
+ const r = indexedDB.open(dbName, 1);
65
+ r.onupgradeneeded = () => r.result.createObjectStore(STORE);
66
+ r.onsuccess = () => res(r.result);
67
+ r.onerror = () => rej(r.error);
68
+ });
69
+ }
70
+ async function idbGet(key) {
71
+ const db = await idb();
72
+ return new Promise((res, rej) => { const t = db.transaction(STORE).objectStore(STORE).get(key); t.onsuccess = () => res(t.result); t.onerror = () => rej(t.error); });
73
+ }
74
+ async function idbPut(key, val) {
75
+ const db = await idb();
76
+ return new Promise((res, rej) => { const t = db.transaction(STORE, 'readwrite').objectStore(STORE).put(val, key); t.onsuccess = () => res(); t.onerror = () => rej(t.error); });
77
+ }
78
+ async function idbDel(key) {
79
+ const db = await idb();
80
+ return new Promise((res, rej) => { const t = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(key); t.onsuccess = () => res(); t.onerror = () => rej(t.error); });
81
+ }
82
+
83
+ /** Begin login: prepare PKCE + a non-extractable DPoP key, stash the pending
84
+ * state, and return the authorization URL for the caller to navigate to. */
85
+ async function beginLogin({ issuer, redirectUri, clientName: name = clientName, returnTo = null }) {
86
+ const cfg = await discover(issuer);
87
+ const client_id = await registerClient(cfg, redirectUri, name);
88
+ const pair = await dpopKey();
89
+ const verifier = rand(48);
90
+ const challenge = b64u(await sha256(verifier));
91
+ const state = rand(16);
92
+ await idbPut('pending', { issuer, client_id, redirectUri, verifier, state, pair, returnTo,
93
+ tokenEndpoint: cfg.token_endpoint, authorizationEndpoint: cfg.authorization_endpoint,
94
+ revocationEndpoint: cfg.revocation_endpoint || null });
95
+ const url = new URL(cfg.authorization_endpoint);
96
+ for (const [k, v] of Object.entries({ client_id, redirect_uri: redirectUri, response_type: 'code',
97
+ scope: 'openid webid offline_access', code_challenge: challenge, code_challenge_method: 'S256', state, prompt: 'consent' })) url.searchParams.set(k, v);
98
+ return { authorizationUrl: url.href };
99
+ }
100
+
101
+ /** Complete login from the redirect back. Exchanges the code (DPoP + PKCE),
102
+ * stores the durable session, and returns it. Returns null if there is no code. */
103
+ async function completeLogin({ currentUrl }) {
104
+ const u = new URL(currentUrl);
105
+ const code = u.searchParams.get('code');
106
+ const state = u.searchParams.get('state');
107
+ if (!code) return null;
108
+ const p = await idbGet('pending');
109
+ if (!p || p.state !== state) throw new Error('login state mismatch');
110
+ const jwk = await publicJwk(p.pair);
111
+ const res = await fetch(p.tokenEndpoint, {
112
+ method: 'POST',
113
+ headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: await dpopProof(p.pair, jwk, 'POST', p.tokenEndpoint) },
114
+ body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: p.redirectUri, client_id: p.client_id, code_verifier: p.verifier }),
115
+ });
116
+ const tok = await res.json().catch(() => ({}));
117
+ if (!res.ok || !tok.access_token) throw new Error(`token exchange failed (HTTP ${res.status}): ${tok.error || ''}`);
118
+ const webId = jwtPayload(tok.access_token).webid || jwtPayload(tok.id_token || '').webid || null;
119
+ const session = { issuer: p.issuer, client_id: p.client_id, tokenEndpoint: p.tokenEndpoint, pair: p.pair,
120
+ // Where to hand the refresh token back at sign-out. Kept on the session
121
+ // because discovery is a network round trip and sign-out should not need
122
+ // one — see signOut().
123
+ revocationEndpoint: p.revocationEndpoint || null,
124
+ refreshToken: tok.refresh_token || null, accessToken: tok.access_token,
125
+ expiresAt: Date.now() + Math.max(30, (tok.expires_in || 300)) * 1000, webId };
126
+ await idbPut('session', session);
127
+ await idbDel('pending');
128
+ // `returnTo` rides only on the handle this call returns: where the person
129
+ // was when they left for the login page, for the caller to send them back.
130
+ return Object.assign(sessionHandle(session), p.returnTo ? { returnTo: p.returnTo } : {});
131
+ }
132
+
133
+ /** Restore the session from IndexedDB — works in the page and the worker. */
134
+ async function getSession() {
135
+ const s = await idbGet('session');
136
+ return s ? sessionHandle(s) : null;
137
+ }
138
+
139
+ /**
140
+ * Sign out, and tell the issuer.
141
+ *
142
+ * Dropping the row locally leaves the REFRESH TOKEN alive at the pod for its
143
+ * full life — signing out of a shared or borrowed browser would clear the
144
+ * screen and nothing else, and anyone who had copied the token could go on
145
+ * minting access tokens with it. RFC 7009: hand it back.
146
+ *
147
+ * Best effort, and the local teardown happens either way: an issuer that is
148
+ * unreachable, or that publishes no revocation endpoint, must not be able to
149
+ * keep somebody signed in.
150
+ */
151
+ async function signOut() {
152
+ const s = await idbGet('session').catch(() => null);
153
+ if (s?.revocationEndpoint && s.refreshToken) {
154
+ try {
155
+ await fetch(s.revocationEndpoint, {
156
+ method: 'POST',
157
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
158
+ body: new URLSearchParams({
159
+ token: s.refreshToken, token_type_hint: 'refresh_token', client_id: s.client_id,
160
+ }),
161
+ });
162
+ } catch { /* told if we could; leaving is not conditional on being heard */ }
163
+ }
164
+ await idbDel('session');
165
+ await idbDel('pending');
166
+ }
167
+
168
+ function sessionHandle(s) {
169
+ let { accessToken, expiresAt, refreshToken } = s;
170
+ const jwkP = publicJwk(s.pair);
171
+ const refresh = async () => {
172
+ if (!refreshToken) throw new Error('session expired and there is no refresh token — sign in again');
173
+ const jwk = await jwkP;
174
+ let res;
175
+ try {
176
+ res = await fetch(s.tokenEndpoint, {
177
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: await dpopProof(s.pair, jwk, 'POST', s.tokenEndpoint) },
178
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: s.client_id, scope: 'openid webid offline_access' }),
179
+ });
180
+ } catch (e) {
181
+ // A renewal that never reached the server is not a refused one: the
182
+ // session is still good, and saying "your pod refused the write" about
183
+ // it sends whoever reads it looking in the wrong place. Under load a
184
+ // server refuses the token endpoint first, and a refusal with no CORS
185
+ // headers arrives here as the browser's bare "Failed to fetch".
186
+ throw new Error(`${e.message} — renewing your sign-in at ${new URL(s.tokenEndpoint).host}`);
187
+ }
188
+ const tok = await res.json().catch(() => ({}));
189
+ if (res.status === 429) {
190
+ throw new Error(`${new URL(s.tokenEndpoint).host} is asking us to slow down — your sign-in could not be renewed just now`);
191
+ }
192
+ if (!res.ok || !tok.access_token) { await idbDel('session'); throw new Error('refresh failed — sign in again'); }
193
+ accessToken = tok.access_token;
194
+ expiresAt = Date.now() + Math.max(30, (tok.expires_in || 300)) * 1000;
195
+ if (tok.refresh_token) refreshToken = tok.refresh_token;
196
+ // Re-read before writing back. There is ONE 'session' row, and this handle
197
+ // closed over the session it was made from — so a refresh that was already
198
+ // in flight when the user signed in as somebody else would put the OLD
199
+ // identity back, silently, over the new one. Only write if the row is still
200
+ // the session this handle belongs to.
201
+ const now = await idbGet('session');
202
+ if (now && now.webId !== s.webId) {
203
+ throw new Error('signed in as somebody else while this session was refreshing');
204
+ }
205
+ await idbPut('session', { ...s, accessToken, expiresAt, refreshToken });
206
+ };
207
+ const send = async (url, init) => {
208
+ const jwk = await jwkP;
209
+ const ath = b64u(await sha256(accessToken));
210
+ const headers = { ...(init.headers || {}), authorization: `DPoP ${accessToken}`, dpop: await dpopProof(s.pair, jwk, (init.method || 'GET'), url, ath) };
211
+ return fetch(url, { ...init, headers });
212
+ };
213
+ const authFetch = async (url, init = {}) => {
214
+ if (Date.now() > expiresAt - 30_000) await refresh();
215
+ const res = await send(url, init);
216
+ // A token the pod REFUSES is not only ever an expired one: a pod that
217
+ // restarted, a clock that drifted, a token the server stopped recognising
218
+ // all answer 401 while the clock here still says the token is good — so the
219
+ // renewal above never fires and every call fails from then on. Renew once
220
+ // and ask again.
221
+ if (res.status === 401 && refreshToken) {
222
+ try { await refresh(); } catch { return res; }
223
+ return send(url, init);
224
+ }
225
+ return res;
226
+ };
227
+ // `issuer` so a page that cannot renew can send the person back to the right
228
+ // login without asking them for their address again.
229
+ return { webId: s.webId, issuer: s.issuer, fetch: authFetch, refresh, signOut };
230
+ }
231
+
232
+ return { beginLogin, completeLogin, getSession, signOut };
233
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "fediverse-account",
3
+ "version": "0.1.0",
4
+ "description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jeff Zucker",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jeff-zucker/FediPod.git",
11
+ "directory": "lib/session"
12
+ },
13
+ "homepage": "https://github.com/jeff-zucker/FediPod/tree/main/lib/session#readme",
14
+ "keywords": [
15
+ "fediverse",
16
+ "activitypub",
17
+ "mastodon",
18
+ "solid",
19
+ "solid-oidc",
20
+ "webid",
21
+ "login",
22
+ "oauth",
23
+ "fedipod"
24
+ ],
25
+ "files": [
26
+ "fedi-account.mjs",
27
+ "fedi-login.mjs",
28
+ "oidc-session.mjs",
29
+ "README.md"
30
+ ],
31
+ "exports": {
32
+ ".": "./fedi-account.mjs",
33
+ "./fedi-account.mjs": "./fedi-account.mjs",
34
+ "./fedi-login.mjs": "./fedi-login.mjs",
35
+ "./oidc-session.mjs": "./oidc-session.mjs"
36
+ },
37
+ "scripts": {
38
+ "prepublishOnly": "node ../../scripts/check-pod-calls.mjs"
39
+ },
40
+ "dependencies": {}
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.21.0",
3
+ "version": "0.28.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/run-agent.mjs CHANGED
@@ -33,6 +33,7 @@ import { apRoot, rootOf, writeJsonAtomic } from './lib/device/home.mjs';
33
33
  import { ensureTrustedTls } from './lib/device/certs.mjs';
34
34
  import { storageFor } from './lib/core/storage.mjs';
35
35
  import { resolveKeys } from './lib/core/keys.mjs';
36
+ import { completeGatewayMove } from './lib/device/gateway-move.mjs';
36
37
  import { RemotePod } from './lib/device/remote.mjs';
37
38
  import { Deliverer } from './lib/core/deliver.mjs';
38
39
  import { Publisher } from './lib/core/publisher/index.mjs';
@@ -497,6 +498,18 @@ export class Agent {
497
498
  return true;
498
499
  }
499
500
 
501
+ // The featured collection was once written only on the first pin, so an
502
+ // account from before then has none, and every server that shows the
503
+ // profile is refused and asks again. One authenticated GET; the empty
504
+ // collection goes up when the pod lacks it. True when it had to.
505
+ async ensureFeaturedPublished() {
506
+ const doc = await this.remote.getJson(this.urls.featured);
507
+ if (doc?.id) return false;
508
+ this.log('featured collection missing from the pod — publishing it');
509
+ await this.publisher.publishFeatured();
510
+ return true;
511
+ }
512
+
500
513
  // Read-only mode: refresh the state cache periodically, and take over the
501
514
  // moment the active agent's lease frees.
502
515
  startViewer() {
@@ -535,6 +548,10 @@ export class Agent {
535
548
  return; // renewal is already running, above
536
549
  }
537
550
  await this.intake.start();
551
+ // An address that moved here from another gateway, whose move was left
552
+ // pending (the old gateway could not be told): try again now that this
553
+ // agent acts and the actor is published.
554
+ completeGatewayMove(this).catch(e => this.log(`gateway move: ${e.message}`));
538
555
  // Reused, not replaced: demote() stops this instance but does not clear it,
539
556
  // so constructing a new one over the top orphaned the old chain beyond the
540
557
  // reach of any later stop().
@@ -588,6 +605,8 @@ export class Agent {
588
605
  if (repair) {
589
606
  this.ensureActorPublished()
590
607
  .catch(e => this.log(`actor check failed: ${e.message}`));
608
+ this.ensureFeaturedPublished()
609
+ .catch(e => this.log(`featured check failed: ${e.message}`));
591
610
  // The human page, rewritten only when what it shows has changed: one
592
611
  // local digest compare, and a write the first time after an upgrade.
593
612
  this.publisher.publishProfilePage()
@@ -730,6 +749,15 @@ export async function startAgent({
730
749
  handle = null,
731
750
  } = {}) {
732
751
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
752
+ // A home can hold a FORUM rather than a person or a group: same profiles
753
+ // directory, same unit, same startup — a different thing to run. Its
754
+ // credential says so, and running the agent over it would publish a person
755
+ // where a forum lives.
756
+ const { isForumHome, runForum } = await import('./packages/fedipod-bb/src/run.mjs');
757
+ if (isForumHome(home)) {
758
+ console.log(`[bb:${port}] this home holds a forum — running it`);
759
+ return runForum({ home, port, log: (...a) => console.log(`[bb:${port}]`, ...a) });
760
+ }
733
761
  // connect() sets this from pod state a moment later, but the browser may
734
762
  // already be opening — seed it from what setup recorded so the named origin
735
763
  // works from the first request.
package/vendor/gate.cjs CHANGED
@@ -7,8 +7,11 @@
7
7
  // authorized when it carries the secret:
8
8
  // 1. header `x-dk-token` — the Electron shell injects this on app traffic, or
9
9
  // 2. cookie `dk-token` — a browser "blessed" via the token URL, or
10
- // 3. query `?dk-token=<secret>` — answered with a SameSite=Strict cookie and
11
- // a redirect to the same URL minus the param (the blessing flow), or
10
+ // 3. query `?dk-token=<secret>` — answered with a SameSite=Lax cookie and
11
+ // a redirect to the same URL minus the param (the blessing flow). Lax,
12
+ // not Strict: a link from another site — the pod server's own opt-in
13
+ // page among them — must arrive with the cookie it was just handed, and
14
+ // Lax still withholds it from cross-site POSTs and subresources; or
12
15
  // 4. (opt-in, proxy only) an Origin/Referer on the allowOrigins list — app
13
16
  // pages in a blessed browser call the proxy with plain fetch(), which
14
17
  // doesn't attach cookies cross-port; a hostile page can't forge Origin.
@@ -105,7 +108,7 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
105
108
  // Path scopes the cookie to this identity's own door: on a shared
106
109
  // origin (suffix pods) two co-tenants must not clobber each other's,
107
110
  // and a whole-origin cookie would. Defaults to '/'.
108
- 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=31536000`
111
+ 'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Lax; Max-Age=31536000`
109
112
  + (secureCookie ? '; Secure' : ''),
110
113
  'location': url.pathname + url.search,
111
114
  });
package/web/admin/bar.css CHANGED
@@ -58,3 +58,69 @@ body { background: var(--surface); }
58
58
  underlined rather than removed. */
59
59
  #bar button[aria-current="page"], #bar a[aria-current="page"] {
60
60
  text-decoration: underline; text-underline-offset: .3rem; }
61
+ /* The client control: which fediverse client this browser opens. The label is
62
+ quieter than the links; the one in use is marked `aria-current="true"` — the
63
+ current item of a set, which on the record page is not the current PAGE — so
64
+ it needs its own mark rather than borrowing the rule above. */
65
+ #bar #client-pick, #bar #account-pick { display: inline-flex; gap: .6rem; align-items: baseline;
66
+ white-space: nowrap; margin-left: 1rem; }
67
+ #bar #client-now, #bar #account-now { opacity: .75; }
68
+ /* The notices bell: the one filled control in the bar, because it is the one
69
+ thing there that is not a place to go but something to be told. The count is
70
+ how many notices this browser has not opened yet; with none it is quiet. */
71
+ #bar #bar-notices { display: inline-flex; align-items: center; gap: .35rem; margin-left: 1rem;
72
+ padding: .3rem .7rem; border-radius: 999px; font-size: 1.05rem; line-height: 1;
73
+ background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn); }
74
+ /* `display` on an id rule outranks the browser's own [hidden] rule, and the
75
+ shell has no !important of its own, so the bell hid on the record page and
76
+ showed on the client with nothing to show. Said here, once, for every page. */
77
+ #bar #bar-notices[hidden] { display: none; }
78
+ #bar #bar-notices:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
79
+ #bar #bar-notices:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
80
+ #bar #bar-notices-count { font-size: .9rem; font-weight: 700; min-width: 1.3em; text-align: center;
81
+ padding: .05rem .4rem; border-radius: 999px; background: var(--btn-text); color: var(--btn); }
82
+ #bar #bar-notices-count:empty { display: none; }
83
+ #bar #bar-notices[data-new="0"] { background: var(--field-bg); color: var(--ink); border-color: var(--field-edge); }
84
+ #bar a[aria-current="true"] {
85
+ font-weight: 700; text-decoration: underline; text-underline-offset: .3rem; }
86
+
87
+ /* The two-clients notice: said once, to somebody who knew this page when it
88
+ had one client. Sized and placed rather than left to the browser's default,
89
+ which stretched it edge to edge and put it over the very links it names. */
90
+ #client-news, #notices-list, #notice-view {
91
+ /* Low rather than centred: it points at the links in the bar, so the bar has
92
+ to stay in view while it is read. */
93
+ max-width: 34rem; margin: auto auto 2.5rem; padding: 1.5rem 1.7rem;
94
+ border: 1px solid var(--dialog-edge); border-radius: .6rem;
95
+ background: var(--raised); color: var(--fg);
96
+ font: 1rem/1.55 var(--font);
97
+ box-shadow: 0 .6rem 2rem rgba(0, 0, 0, .25);
98
+ }
99
+ #client-news::backdrop, #notices-list::backdrop, #notice-view::backdrop { background: rgba(0, 0, 0, .45); }
100
+ #client-news h2, #notices-list h2, #notice-view h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
101
+ #client-news p, #notices-list p, #notice-view p { margin: 0 0 1.2rem; }
102
+ #client-news p:last-child, #notices-list p:last-child, #notice-view p:last-child { margin: 0; }
103
+ #client-news button, #notices-list button, #notice-view button {
104
+ font: inherit; padding: .55rem 1.3rem; border-radius: .35rem; cursor: pointer;
105
+ background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn);
106
+ }
107
+ #client-news button:hover, #notices-list button:hover, #notice-view button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
108
+ #client-news button:focus-visible, #notices-list button:focus-visible, #notice-view button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
109
+
110
+ /* The notices dialogs are centred and never taller than the window: the
111
+ client-news rule above sits low, and a short window pushed a list of
112
+ notices up past the top edge where nothing could reach it. */
113
+ #notices-list, #notice-view { margin: auto; max-height: calc(100dvh - 3rem); overflow: auto; box-sizing: border-box; }
114
+ /* The list of notices: each title a button that opens that notice, the date
115
+ beside it, the ones not yet opened here in bold. */
116
+ #notices-items { list-style: none; margin: 0 0 1.2rem; padding: 0; }
117
+ #notices-items li { display: flex; gap: .8rem; align-items: baseline; padding: .45rem 0;
118
+ border-bottom: 1px solid var(--line); }
119
+ #notices-items li:last-child { border-bottom: 0; }
120
+ #notices-items button.notice-open { flex: 1 1 auto; text-align: left; padding: .1rem 0;
121
+ background: none; border: 0; color: var(--link); text-decoration: underline; text-underline-offset: .2rem; }
122
+ #notices-items li.new button.notice-open { font-weight: 700; }
123
+ #notices-items .when, #notice-view .when { color: var(--sub); font-size: .9rem; white-space: nowrap; }
124
+ #notices-items p.none { color: var(--sub); }
125
+ #notice-view-body p { margin: 0 0 .9rem; }
126
+ #notice-view-body a { color: var(--link); word-break: break-all; }
package/web/admin/bar.js CHANGED
@@ -24,6 +24,10 @@
24
24
  if (el && s?.handle && s?.actor) {
25
25
  try { el.textContent = `@${s.handle}@${new URL(s.actor).host}`; } catch { /* odd actor url */ }
26
26
  }
27
+ // A viewer does nothing: it publishes no profile, carries nothing and
28
+ // leaves the account looking broken from outside. Say so where the
29
+ // account is, and let the owner take it back from here.
30
+ if (s?.mode === 'viewer') viewerBanner();
27
31
  }).catch(() => { /* not up yet; the bar still works */ });
28
32
 
29
33
  // The actors dropdown, on a page whose bar carries one. The record page
@@ -59,3 +63,37 @@
59
63
  }).catch(() => { pick.hidden = true; });
60
64
  }
61
65
  })();
66
+
67
+ // "Another device is active": what it means for this account, and the way out.
68
+ // The lease is a document on the pod with a five-minute life; a browser that
69
+ // was closed leaves one behind that nothing renews, and every reload asks
70
+ // under a new name, so waiting can look like waiting forever.
71
+ function viewerBanner() {
72
+ if (document.getElementById('viewer-banner')) return;
73
+ const base = location.pathname.replace(/\/admin\/.*$/u, '');
74
+ const bar = document.createElement('div');
75
+ bar.id = 'viewer-banner';
76
+ bar.setAttribute('role', 'status');
77
+ bar.style.cssText = 'padding:.7rem 1rem;background:var(--field-bg,#ececec);color:var(--fg,#1a1a1a);'
78
+ + 'border-bottom:1px solid var(--line,#b0b0b0);display:flex;gap:1rem;align-items:center;flex-wrap:wrap;font:inherit';
79
+ const said = document.createElement('span');
80
+ said.textContent = 'Another device holds this account, so nothing is being published or delivered from here.';
81
+ const go = document.createElement('button');
82
+ go.type = 'button';
83
+ go.textContent = 'Take it over';
84
+ go.style.cssText = 'font:inherit;font-weight:600;padding:.4rem 1rem;border-radius:.4rem;border:1px solid var(--btn,#3a5f43);'
85
+ + 'background:var(--btn,#3a5f43);color:var(--btn-text,#fff);cursor:pointer';
86
+ go.addEventListener('click', async () => {
87
+ go.disabled = true;
88
+ said.textContent = 'Taking it over…';
89
+ try {
90
+ const r = await fetch(base + '/takeover', { method: 'POST', headers: { 'x-fedipod-page': '1' } });
91
+ const j = await r.json().catch(() => null);
92
+ if (r.ok && j?.mode === 'active') { location.reload(); return; }
93
+ said.textContent = j?.error || 'The lease could not be taken.';
94
+ } catch (e) { said.textContent = e.message; }
95
+ go.disabled = false;
96
+ });
97
+ bar.append(said, go);
98
+ document.body.prepend(bar);
99
+ }
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>FediPod — client</title>
7
+ <link rel="icon" href="/icons/local.svg" type="image/svg+xml">
7
8
  <link rel="stylesheet" href="../tokens.css">
8
9
  <style>
9
10
  /* Colours are the shared tokens (tokens.css); only layout is here. The bar is
@@ -11,7 +12,7 @@
11
12
  does. */
12
13
  :root { color-scheme: light dark; font-size: 125%; }
13
14
  html { height: 100%; }
14
- body { font: 1rem/1.5 system-ui, sans-serif; margin: 0; height: 100dvh;
15
+ body { font: 1rem/1.5 Arial, Helvetica, sans-serif; margin: 0; height: 100dvh;
15
16
  display: flex; flex-direction: column; overflow: hidden; }
16
17
  main { flex: 1 1 auto; min-height: 0; display: flex; }
17
18
  #client { flex: 1 1 auto; min-height: 0; width: 100%; border: 0; }
@@ -27,8 +28,16 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
27
28
  <h1 class="name">FediPod</h1>
28
29
  <select id="actor-pick" aria-label="Local actors"
29
30
  title=" Every actor on this machine; choosing one opens its client"></select>
30
- <a id="bar-fediverse" href="./" title=" Open the client and read your timeline" aria-current="page">visit account</a>
31
- <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage account</a>
31
+ <!-- The account group, styled like the client group beside it: one label,
32
+ then the places to go. The browser build adds "sign out" to it. -->
33
+ <span id="account-pick"><span id="account-now">account:</span>
34
+ <a id="bar-fediverse" href="./" title=" Open the client and read your timeline" aria-current="page">visit</a>
35
+ <a id="bar-manage" href="../" title=" The record: what this actor is, and what you can do to it">manage</a>
36
+ </span>
37
+ <!-- Notices from whoever runs this site. Shown only when the site has any
38
+ (notices-bar.js asks /api/notices); the count is how many are new here. -->
39
+ <button type="button" id="bar-notices" hidden aria-haspopup="dialog" aria-controls="notices-list"
40
+ title=" Notices from the operator of this site">&#128276;<span id="bar-notices-count" aria-live="polite"></span></button>
32
41
  </nav>
33
42
 
34
43
  <main>
@@ -37,5 +46,24 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
37
46
 
38
47
  <script src="../bar.js"></script>
39
48
  <script src="client.js"></script>
49
+
50
+ <!-- The notices: the list, and one notice open. Declared here and filled by
51
+ notices-bar.js from /api/notices; nothing in them is markup from a notice. -->
52
+ <dialog id="notices-list" aria-labelledby="notices-title">
53
+ <section>
54
+ <h2 id="notices-title">Notices</h2>
55
+ <ul id="notices-items"></ul>
56
+ <p class="row"><button type="button" id="notices-close" class="primary" title=" Close the list of notices">Close</button></p>
57
+ </section>
58
+ </dialog>
59
+ <dialog id="notice-view" aria-labelledby="notice-view-title">
60
+ <section>
61
+ <h2 id="notice-view-title"></h2>
62
+ <p class="when" id="notice-view-when"></p>
63
+ <div id="notice-view-body"></div>
64
+ <p class="row"><button type="button" id="notice-view-close" class="primary" title=" Back to the list of notices">Close</button></p>
65
+ </section>
66
+ </dialog>
67
+ <script src="../notices-bar.js"></script>
40
68
  </body>
41
69
  </html>
@@ -0,0 +1,79 @@
1
+ // client-pick.js — which fediverse client this browser opens.
2
+ //
3
+ // The site carries more than one client. Each one has its own shell page whose
4
+ // markup declares the app it frames, and every switch is an ordinary link from
5
+ // one shell to another. This file adds one thing only: it REMEMBERS which shell
6
+ // you last chose, so /admin/client/ — where signing in lands, and where the
7
+ // record's "visit account" goes — hands you back the same client next time.
8
+ //
9
+ // The choice is this browser's, not the account's. It decides which page you
10
+ // are given and nothing else: both clients talk to the same agent, with the
11
+ // same bearer, on this same origin. Nothing about the identity moves with it,
12
+ // which is why it lives in localStorage and is never written to the pod.
13
+ //
14
+ // Staged into the site by scripts/stage-site.mjs, which also generates the
15
+ // shells and the links in the bar. The device agent carries one client and
16
+ // never loads this.
17
+
18
+ const KEY = 'fedipod-client';
19
+ // The default client's shell. It is also the page that redirects, so a browser
20
+ // that has chosen something else does not have to pass through the default.
21
+ const DEFAULT_SHELL = '/admin/client/';
22
+ // A stored value is a path this site generated, and nothing else is followed.
23
+ // Without this, anything that can write localStorage — including the framed
24
+ // client itself — could send the next visit off this origin.
25
+ const SHELL = /^\/admin\/client(-[a-z0-9-]+)?\/$/;
26
+
27
+ const read = () => {
28
+ // A private window, or a browser told to keep no site data, throws on the
29
+ // read rather than answering empty. There is no choice to honour then, which
30
+ // is the same as not having made one.
31
+ try { return localStorage.getItem(KEY) || ''; } catch { return ''; }
32
+ };
33
+
34
+ // Before anything renders: if this browser last chose another client, that is
35
+ // the page it asked for. `replace` and not `href`, so Back does not bounce
36
+ // between the two shells.
37
+ if (location.pathname === DEFAULT_SHELL) {
38
+ const chosen = read();
39
+ if (chosen && chosen !== DEFAULT_SHELL && SHELL.test(chosen)) location.replace(chosen);
40
+ }
41
+
42
+ // Somebody who knew this page when it had one client should be told, once,
43
+ // that it now has two and where to change it. Marked as said as soon as it is
44
+ // shown rather than when it is dismissed: a reader who closes the tab instead
45
+ // of pressing the button has still read it, and being told twice is worse than
46
+ // not being told a second time.
47
+ const SAID = 'fedipod-client-news';
48
+ function sayOnce() {
49
+ const dlg = document.getElementById('client-news');
50
+ if (!dlg) return;
51
+ let told = true;
52
+ try { told = !!localStorage.getItem(SAID); } catch { /* keeps no site data */ }
53
+ if (told) return;
54
+ try { localStorage.setItem(SAID, '1'); } catch { /* keeps no site data */ }
55
+ dlg.showModal();
56
+ document.getElementById('client-news-ok')?.addEventListener('click', () => dlg.close());
57
+ }
58
+
59
+ document.addEventListener('DOMContentLoaded', () => {
60
+ sayOnce();
61
+ // Which one is current. On a shell it is the page you are on — you may have
62
+ // typed its address, and what is in front of you is the true answer whatever
63
+ // storage says. Anywhere else (the record page) it is the remembered choice.
64
+ const here = SHELL.test(location.pathname) ? location.pathname : (read() || DEFAULT_SHELL);
65
+
66
+ for (const a of document.querySelectorAll('a.client-switch')) {
67
+ // `aria-current` and not a class alone: this is one of a set of links, one
68
+ // of which is the one in use — which is the state the attribute exists to
69
+ // carry, and the only way a screen reader is told about it at all.
70
+ if (a.getAttribute('href') === here) a.setAttribute('aria-current', 'true');
71
+ // Each link records the choice it stands for and then follows its own href,
72
+ // the destination written in the markup. Recorded on the way OUT rather
73
+ // than on arrival, so a shell opened deliberately by address — or the one
74
+ // the redirect above lands on — does not silently become the default.
75
+ a.addEventListener('click', () => {
76
+ try { localStorage.setItem(KEY, a.getAttribute('href')); } catch { /* keeps no site data */ }
77
+ });
78
+ }
79
+ });