nixamp 0.28.37 → 0.28.41

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 (116) hide show
  1. package/README.md +6 -1
  2. package/backtoschool/dist/assets/{ar-CmpEhms8.js → ar-BQPKp0M2.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-BiiJOLTm.js → cs-DjJnhGNB.js} +1 -1
  4. package/{web/dist/assets/da-M4GDhrcC.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
  5. package/{web/dist/assets/de-D7yI8cd_.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
  6. package/backtoschool/dist/assets/{es-BwQQh_C8.js → es-BBlBPcq1.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-DKPYMsbo.js → fi-ffo35_4W.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-DNekMW0h.js → fr-BVvavrk7.js} +1 -1
  9. package/{web/dist/assets/hi-DO7CO-fI.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
  10. package/{web/dist/assets/hls-3VKVEQE3-BCxdTCEU.js → backtoschool/dist/assets/hls-3VKVEQE3-QZWXCWjR.js} +1 -1
  11. package/backtoschool/dist/assets/hls-n74Cnh8A.js +42 -0
  12. package/backtoschool/dist/assets/{hu-gcw9ouWH.js → hu-BoJj7pZa.js} +1 -1
  13. package/backtoschool/dist/assets/{id-Bz51zxz6.js → id-CXN9gchF.js} +1 -1
  14. package/backtoschool/dist/assets/index-BaiHu9CD.css +1 -0
  15. package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
  16. package/backtoschool/dist/assets/{it-jO8u4ie7.js → it-C9g0xD6S.js} +1 -1
  17. package/{web/dist/assets/mpegts-DsEoUEPH.js → backtoschool/dist/assets/mpegts-GAwbg-pX.js} +1 -1
  18. package/{web/dist/assets/mpegts-LO6RVLD6-CC8jMmPy.js → backtoschool/dist/assets/mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
  19. package/backtoschool/dist/assets/native-C7JTKWJH-BUyIoj0P.js +1 -0
  20. package/{web/dist/assets/nl-BU0Mn-e-.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
  21. package/{web/dist/assets/ru-ClqNCOTW.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
  22. package/backtoschool/dist/assets/{sv-CV2-POKP.js → sv-DH0ZO3Eh.js} +1 -1
  23. package/{web/dist/assets/uk-K5yQBF0h.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
  24. package/backtoschool/dist/assets/{vi-BDZew-O9.js → vi-D0QPpzbE.js} +1 -1
  25. package/{web/dist/assets/zh-a2v2x-3e.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
  26. package/backtoschool/dist/index.html +61 -10
  27. package/dist/classroom.d.ts +1 -1
  28. package/dist/classroom.js +4 -0
  29. package/dist/live-api.d.ts +10 -0
  30. package/dist/live-api.js +8 -0
  31. package/dist/live-events.js +1 -1
  32. package/dist/live-voice.d.ts +3 -0
  33. package/dist/live-voice.js +27 -4
  34. package/dist/locales/ar.d.ts +15 -0
  35. package/dist/locales/cs.d.ts +15 -0
  36. package/dist/locales/da.d.ts +15 -0
  37. package/dist/locales/de.d.ts +15 -0
  38. package/dist/locales/en.d.ts +15 -0
  39. package/dist/locales/es.d.ts +15 -0
  40. package/dist/locales/fi.d.ts +15 -0
  41. package/dist/locales/fr.d.ts +15 -0
  42. package/dist/locales/hi.d.ts +15 -0
  43. package/dist/locales/hu.d.ts +15 -0
  44. package/dist/locales/id.d.ts +15 -0
  45. package/dist/locales/it.d.ts +15 -0
  46. package/dist/locales/nl.d.ts +15 -0
  47. package/dist/locales/platforms.d.ts +19 -0
  48. package/dist/locales/platforms.js +19 -0
  49. package/dist/locales/ru.d.ts +15 -0
  50. package/dist/locales/sv.d.ts +15 -0
  51. package/dist/locales/uk.d.ts +15 -0
  52. package/dist/locales/vi.d.ts +15 -0
  53. package/dist/locales/zh.d.ts +15 -0
  54. package/dist/nixamp-link-api.d.ts +44 -0
  55. package/dist/nixamp-link-api.js +190 -0
  56. package/dist/nixamp-link-types.d.ts +33 -0
  57. package/dist/nixamp-link-types.js +5 -0
  58. package/dist/nixamp-link.d.ts +66 -0
  59. package/dist/nixamp-link.js +231 -0
  60. package/dist/notify.d.ts +6 -4
  61. package/dist/notify.js +14 -25
  62. package/dist/oauth-api.d.ts +1 -1
  63. package/dist/oauth-api.js +27 -9
  64. package/dist/oauth-server.d.ts +9 -1
  65. package/dist/oauth-server.js +24 -2
  66. package/dist/profiles.d.ts +85 -0
  67. package/dist/profiles.js +0 -0
  68. package/dist/server.d.ts +12 -2
  69. package/dist/server.js +205 -13
  70. package/dist/voice-provider.d.ts +9 -0
  71. package/dist/voice-provider.js +27 -0
  72. package/dist/watch-party.d.ts +8 -0
  73. package/dist/watch-party.js +21 -0
  74. package/package.json +6 -6
  75. package/src/classroom.ts +5 -1
  76. package/src/live-api.ts +13 -0
  77. package/src/live-events.ts +1 -1
  78. package/src/live-voice.ts +25 -3
  79. package/src/locales/platforms.ts +21 -0
  80. package/src/nixamp-link-api.ts +220 -0
  81. package/src/nixamp-link-types.ts +36 -0
  82. package/src/nixamp-link.ts +265 -0
  83. package/src/notify.ts +19 -34
  84. package/src/oauth-api.ts +30 -11
  85. package/src/oauth-server.ts +24 -2
  86. package/src/profiles.ts +0 -0
  87. package/src/server.ts +192 -15
  88. package/src/voice-provider.ts +22 -0
  89. package/src/watch-party.ts +23 -0
  90. package/web/dist/assets/{ar-DhdXPO3p.js → ar-BqLaLSMR.js} +1 -1
  91. package/web/dist/assets/{cs-CoTzYnZt.js → cs-CfoQJwKM.js} +1 -1
  92. package/{backtoschool/dist/assets/da-DueRd5zz.js → web/dist/assets/da-8MmH23fh.js} +1 -1
  93. package/{backtoschool/dist/assets/de-DIG6WxUZ.js → web/dist/assets/de-Brry3OMh.js} +1 -1
  94. package/web/dist/assets/{es-BMGb7XlT.js → es-CGxJs7b8.js} +1 -1
  95. package/web/dist/assets/{fi-CXNsmIrh.js → fi-BDAAZtto.js} +1 -1
  96. package/web/dist/assets/{fr-BpvyrESQ.js → fr-9sxYL0Qy.js} +1 -1
  97. package/{backtoschool/dist/assets/hi-CGlN72JQ.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
  98. package/web/dist/assets/hls-3VKVEQE3-Dx7qltzA.js +1 -0
  99. package/web/dist/assets/{hu-CosB3vlS.js → hu-CvB9aIyN.js} +1 -1
  100. package/web/dist/assets/{id-DNumDlwq.js → id-Bmrh06Hw.js} +1 -1
  101. package/web/dist/assets/index-BQuoiKg9.js +3 -0
  102. package/web/dist/assets/{index-jLCo7PIA.css → index-Bo3utcDw.css} +1 -1
  103. package/web/dist/assets/{it-IJKhyCGw.js → it-SbaxFUIP.js} +1 -1
  104. package/web/dist/assets/mpegts-BdwONu8t.js +3 -0
  105. package/web/dist/assets/mpegts-LO6RVLD6-CqW3DT1n.js +1 -0
  106. package/{backtoschool/dist/assets/nl-CnYXq0K4.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
  107. package/{backtoschool/dist/assets/ru-BatrUfYu.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
  108. package/web/dist/assets/{sv-Df_aXTD6.js → sv-BAyIUHP5.js} +1 -1
  109. package/{backtoschool/dist/assets/uk-vLtqKpHm.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
  110. package/web/dist/assets/{vi-CYfcOUeA.js → vi-BdH3SLlR.js} +1 -1
  111. package/{backtoschool/dist/assets/zh-C9hBmQKA.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
  112. package/web/dist/index.html +33 -2
  113. package/web/dist/sw.js +23 -23
  114. package/backtoschool/dist/assets/index-CP4oTs6H.css +0 -1
  115. package/backtoschool/dist/assets/index-DDGO_FOw.js +0 -113
  116. package/web/dist/assets/index-CY3lAwM2.js +0 -3
package/dist/server.d.ts CHANGED
@@ -11,6 +11,8 @@ import { type ResetMail } from "./password-reset.ts";
11
11
  import { Handles } from "./handles.ts";
12
12
  import { OpenDirs } from "./opendirs.ts";
13
13
  import { Servers } from "./servers.ts";
14
+ import { AccountProfiles } from "./profiles.ts";
15
+ import { NixampLinks } from "./nixamp-link.ts";
14
16
  import { SignIn } from "./oauth.ts";
15
17
  import { AuthorizationServer } from "./oauth-server.ts";
16
18
  import { WatchParties } from "./watch-party.ts";
@@ -29,6 +31,7 @@ import { Names } from "./names.ts";
29
31
  import { Certs } from "./certs.ts";
30
32
  import { type Throttle } from "@profullstack/throttle";
31
33
  import { type Notification } from "./notify.ts";
34
+ import { type VapidKeys } from "@profullstack/notifications/server";
32
35
  import { EventWriter } from "./event-writer.ts";
33
36
  import { LiveEvents, type LiveEvent } from "./live-events.ts";
34
37
  import { Tickets } from "./tickets.ts";
@@ -669,6 +672,10 @@ export interface HandlerOptions {
669
672
  servers?: Servers;
670
673
  /** The name other people see, which is never the address they signed up with. */
671
674
  handles?: Handles;
675
+ /** The account's card: a name, a homepage, a photo, a line. One table, read by every site here. */
676
+ accountProfiles?: AccountProfiles;
677
+ /** nixamp accounts connected to accounts here, where this site is a client of nixamp.com. */
678
+ links?: NixampLinks;
672
679
  /** Open directories people have found, which anyone may read. */
673
680
  openDirs?: OpenDirs;
674
681
  /** True when this instance is reached over https, for the cookie's Secure. */
@@ -764,8 +771,11 @@ export interface HandlerOptions {
764
771
  * Response it is refused with.
765
772
  */
766
773
  throttle?: Throttle;
767
- /** The VAPID public key a browser needs before it can subscribe. */
768
- vapidPublicKey?: string;
774
+ /**
775
+ * The VAPID pair, read from the environment when the server starts. Only the
776
+ * public half leaves: it is what a browser needs before it can subscribe.
777
+ */
778
+ vapidKeys?: VapidKeys | null;
769
779
  }
770
780
  /**
771
781
  * The whole HTTP surface, as a plain function of a request — so a test can
package/dist/server.js CHANGED
@@ -33,10 +33,13 @@ import { passwordResetPage } from "./password-reset-page.js";
33
33
  import { anonymousHandle, Handles } from "./handles.js";
34
34
  import { OpenDirs } from "./opendirs.js";
35
35
  import { Servers } from "./servers.js";
36
+ import { AccountProfiles, hostCard, PHOTO_LIMIT } from "./profiles.js";
37
+ import { NixampLinks, nixampExchange } from "./nixamp-link.js";
38
+ import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.js";
36
39
  import { DeviceGrants } from "./device.js";
37
40
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
38
41
  import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
39
- import { AuthorizationServer, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
42
+ import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
40
43
  import { handleOAuthApi, oauthApiPath } from "./oauth-api.js";
41
44
  import { WatchParties } from "./watch-party.js";
42
45
  import { needsAdmin, needsMember, Owner } from "./owner.js";
@@ -66,6 +69,7 @@ import { forbiddenLibrary, readLibrary } from "./library.js";
66
69
  import { createThrottle, presentedCredential } from "@profullstack/throttle";
67
70
  import { Durable } from "./durable.js";
68
71
  import { notifyAll, resendEmail, webPush } from "./notify.js";
72
+ import { vapidKeysFromEnv, vapidPublicKeyResponse } from "@profullstack/notifications/server";
69
73
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.js";
70
74
  import { EventWriter } from "./event-writer.js";
71
75
  import { handleLiveApi } from "./live-api.js";
@@ -1138,6 +1142,12 @@ export function isSignInPath(path) {
1138
1142
  path === "/api/v1/servers" ||
1139
1143
  path.startsWith("/api/v1/servers/") ||
1140
1144
  path === "/api/v1/me/handle" ||
1145
+ path === "/api/v1/me/profile" ||
1146
+ path.startsWith("/api/v1/me/profile/") ||
1147
+ // A stored photo is public: it is on every class the host runs.
1148
+ path.startsWith("/api/v1/profiles/") ||
1149
+ // "Connect nixamp" on a site that is a client of nixamp.com.
1150
+ nixampLinkPath(path) ||
1141
1151
  // Public to read, so it must not be behind a share key either.
1142
1152
  path === "/api/v1/opendirs" ||
1143
1153
  path.startsWith("/api/v1/opendirs/") ||
@@ -1447,6 +1457,18 @@ export function createHandler(engine, options) {
1447
1457
  secureCookies: options.secureCookies ?? false,
1448
1458
  }))
1449
1459
  return;
1460
+ // A site here that is a client of nixamp.com: connect, callback, streams.
1461
+ if (options.links && options.accounts && options.authServer && await handleNixampLinkApi(request, response, url, {
1462
+ links: options.links,
1463
+ accounts: options.accounts,
1464
+ issuer: options.site ?? DEFAULT_DIRECTORY,
1465
+ site: accountSite,
1466
+ redirectUris: options.authServer.client(BACKTOSCHOOL_CLIENT.id)?.redirectUris ?? [],
1467
+ home: "/#settings",
1468
+ secureCookies: options.secureCookies ?? false,
1469
+ fetcher: options.links.fetcher,
1470
+ }))
1471
+ return;
1450
1472
  if (options.events && await handleLiveApi(request, response, url, {
1451
1473
  events: options.events,
1452
1474
  ...(options.eventWriter ? { eventWriter: options.eventWriter } : {}),
@@ -1457,6 +1479,9 @@ export function createHandler(engine, options) {
1457
1479
  ...(eventSite ? { site: eventSite } : {}),
1458
1480
  ...(options.invites?.email ? { email: options.invites.email } : {}),
1459
1481
  ...(options.onEventUpdated ? { onEventUpdated: options.onEventUpdated } : {}),
1482
+ ...(options.accountProfiles
1483
+ ? { hostProfile: async (userId) => hostCard(await options.accountProfiles.of(userId), userId, accountSite) }
1484
+ : {}),
1460
1485
  }))
1461
1486
  return;
1462
1487
  // The page explaining the reminder texts. Public for the same reason the
@@ -1781,9 +1806,12 @@ export function createHandler(engine, options) {
1781
1806
  // --- where to reach a follower ----------------------------------------
1782
1807
  if (path.startsWith("/api/v1/notify") && options.follows && options.accounts) {
1783
1808
  // The key is public by design: it is what a browser needs before it can
1784
- // ask permission, and it is useless without the private half.
1809
+ // ask permission, and it is useless without the private half. Served at
1810
+ // run time, never built into the page: a key baked in at build time is
1811
+ // how push quietly becomes "not supported" in production. 503 when this
1812
+ // instance has no keys, so the browser can say so.
1785
1813
  if (path === "/api/v1/notify/key" && request.method === "GET") {
1786
- json(response, 200, { publicKey: options.vapidPublicKey ?? "" });
1814
+ await answerWith(response, vapidPublicKeyResponse(options.vapidKeys));
1787
1815
  return;
1788
1816
  }
1789
1817
  const me = await options.accounts.whoIs(tokenFrom(request.headers));
@@ -1826,7 +1854,19 @@ export function createHandler(engine, options) {
1826
1854
  }
1827
1855
  if (path === "/api/v1/notify/subscribe") {
1828
1856
  if (request.method === "DELETE") {
1829
- const endpoint = url.searchParams.get("endpoint") ?? "";
1857
+ // The endpoint arrives in the query (older pages) or as the JSON
1858
+ // body @profullstack/notifications sends: { endpoint }.
1859
+ let endpoint = url.searchParams.get("endpoint") ?? "";
1860
+ if (!endpoint) {
1861
+ try {
1862
+ const body = JSON.parse(await readBody(request));
1863
+ if (typeof body.endpoint === "string")
1864
+ endpoint = body.endpoint;
1865
+ }
1866
+ catch {
1867
+ // No body: nothing to forget.
1868
+ }
1869
+ }
1830
1870
  await follows.removePush(endpoint);
1831
1871
  json(response, 200, { ok: true });
1832
1872
  return;
@@ -3137,6 +3177,146 @@ export function createHandler(engine, options) {
3137
3177
  json(response, 405, { error: "GET or PUT" });
3138
3178
  return;
3139
3179
  }
3180
+ // --- the account's card -------------------------------------------------
3181
+ //
3182
+ // A name, a homepage, a photo and a line, said once. A class is made from
3183
+ // it, a settings page edits it, and an OpenProfile.md fills it in one move.
3184
+ if ((path === "/api/v1/me/profile" || path.startsWith("/api/v1/me/profile/")) && options.accountProfiles && options.accounts) {
3185
+ const profiles = options.accountProfiles;
3186
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
3187
+ if (who === null) {
3188
+ json(response, 401, { error: "not signed in" });
3189
+ return;
3190
+ }
3191
+ const persona = options.handles ? await options.handles.persona(who.id) : null;
3192
+ const answer = async () => {
3193
+ const profile = await profiles.of(who.id);
3194
+ // Read again: an import may just have remembered the address.
3195
+ const now = options.handles ? await options.handles.persona(who.id) : null;
3196
+ json(response, 200, {
3197
+ ...profile,
3198
+ card: hostCard(profile, who.id, accountSite),
3199
+ openProfile: now?.profile ?? "",
3200
+ handle: now?.handle || fallbackHandle(who.id),
3201
+ });
3202
+ };
3203
+ if (path === "/api/v1/me/profile") {
3204
+ if (request.method === "GET") {
3205
+ await answer();
3206
+ return;
3207
+ }
3208
+ if (request.method === "PUT" || request.method === "POST") {
3209
+ let body;
3210
+ try {
3211
+ body = JSON.parse(await readBody(request));
3212
+ }
3213
+ catch {
3214
+ json(response, 400, { error: "bad JSON" });
3215
+ return;
3216
+ }
3217
+ const written = await profiles.set(who.id, body);
3218
+ if (written.error) {
3219
+ json(response, 422, { error: written.error });
3220
+ return;
3221
+ }
3222
+ await answer();
3223
+ return;
3224
+ }
3225
+ json(response, 405, { error: "GET or PUT" });
3226
+ return;
3227
+ }
3228
+ if (path === "/api/v1/me/profile/import") {
3229
+ if (request.method !== "POST") {
3230
+ json(response, 405, { error: "POST only" });
3231
+ return;
3232
+ }
3233
+ let body = {};
3234
+ try {
3235
+ const raw = await readBody(request);
3236
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
3237
+ }
3238
+ catch {
3239
+ json(response, 400, { error: "bad JSON" });
3240
+ return;
3241
+ }
3242
+ const given = typeof body.url === "string" ? body.url.trim() : "";
3243
+ const from = given || persona?.profile || "";
3244
+ if (from === "") {
3245
+ json(response, 422, { error: "give the URL of your OpenProfile.md, like https://you.example/.well-known/openprofile.md" });
3246
+ return;
3247
+ }
3248
+ const imported = await profiles.importFrom(who.id, from);
3249
+ if (imported.error) {
3250
+ json(response, 422, { error: imported.error });
3251
+ return;
3252
+ }
3253
+ // The address is remembered, so the phone line reads the same file
3254
+ // for the voice, and a later import needs no URL.
3255
+ if (given && options.handles && given !== persona?.profile) {
3256
+ await options.handles.describe(who.id, fallbackHandle(who.id), { profile: given });
3257
+ }
3258
+ await answer();
3259
+ return;
3260
+ }
3261
+ if (path === "/api/v1/me/profile/photo") {
3262
+ if (request.method === "PUT" || request.method === "POST") {
3263
+ let bytes;
3264
+ try {
3265
+ bytes = await readBytes(request, PHOTO_LIMIT);
3266
+ }
3267
+ catch {
3268
+ json(response, 413, { error: "a photo may be up to 1 MB" });
3269
+ return;
3270
+ }
3271
+ const kept = await profiles.setPhoto(who.id, bytes);
3272
+ if (kept.error) {
3273
+ json(response, 422, { error: kept.error });
3274
+ return;
3275
+ }
3276
+ await answer();
3277
+ return;
3278
+ }
3279
+ if (request.method === "DELETE") {
3280
+ await profiles.removePhoto(who.id);
3281
+ await answer();
3282
+ return;
3283
+ }
3284
+ json(response, 405, { error: "PUT or DELETE" });
3285
+ return;
3286
+ }
3287
+ json(response, 404, { error: "no such profile route" });
3288
+ return;
3289
+ }
3290
+ // The stored photo, public: it is on every class the host runs, and a
3291
+ // page shows it to strangers by design. Cached by its etag.
3292
+ if (path.startsWith("/api/v1/profiles/") && path.endsWith("/photo") && options.accountProfiles) {
3293
+ if (request.method !== "GET" && request.method !== "HEAD") {
3294
+ json(response, 405, { error: "GET only" });
3295
+ return;
3296
+ }
3297
+ const userId = decodeURIComponent(path.slice("/api/v1/profiles/".length, -"/photo".length));
3298
+ const photo = userId === "" ? null : await options.accountProfiles.photo(userId);
3299
+ if (photo === null) {
3300
+ json(response, 404, { error: "no photo" });
3301
+ return;
3302
+ }
3303
+ const etag = `"${photo.etag}"`;
3304
+ if (request.headers["if-none-match"] === etag) {
3305
+ response.writeHead(304, { etag, "cache-control": "public, max-age=86400" });
3306
+ response.end();
3307
+ return;
3308
+ }
3309
+ response.writeHead(200, {
3310
+ "content-type": photo.type,
3311
+ "content-length": photo.bytes.length,
3312
+ etag,
3313
+ "cache-control": "public, max-age=86400",
3314
+ "x-content-type-options": "nosniff",
3315
+ "access-control-allow-origin": "*",
3316
+ });
3317
+ response.end(request.method === "HEAD" ? undefined : Buffer.from(photo.bytes));
3318
+ return;
3319
+ }
3140
3320
  // --- the servers this account runs ----------------------------------
3141
3321
  //
3142
3322
  // Kept against the account rather than the machine, so the list reads the
@@ -5497,7 +5677,11 @@ function sendFile(request, response, file) {
5497
5677
  export function createServer(engine, options) {
5498
5678
  const handle = createHandler(engine, options);
5499
5679
  const onRequest = (request, response) => {
5500
- handle(request, response).catch(() => {
5680
+ handle(request, response).catch((error) => {
5681
+ // Silent by default: a stack trace per bad request is a log nobody
5682
+ // reads. Said aloud when asked, which is how a 500 in a test is found.
5683
+ if (process.env["NIXAMP_DEBUG"])
5684
+ console.error(error);
5501
5685
  if (!response.headersSent)
5502
5686
  json(response, 500, { error: "server error" });
5503
5687
  else
@@ -5849,8 +6033,8 @@ export async function serve(argv, version = "0.1.0") {
5849
6033
  // The two things that were promises kept only in memory: a caller who was
5850
6034
  // told they would be texted, and the ended stream a code still points at.
5851
6035
  const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
5852
- const vapidPublicKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
5853
- const vapidPrivateKey = process.env["VAPID_PRIVATE_KEY"] ?? "";
6036
+ // Read at run time, and null unless both halves are set.
6037
+ const vapidKeys = vapidKeysFromEnv(process.env);
5854
6038
  /**
5855
6039
  * Tell a broadcaster's followers, on whatever they asked to be told on.
5856
6040
  *
@@ -5888,11 +6072,10 @@ export async function serve(argv, version = "0.1.0") {
5888
6072
  }),
5889
6073
  }
5890
6074
  : {}),
5891
- ...(vapidPublicKey && vapidPrivateKey
6075
+ ...(vapidKeys
5892
6076
  ? {
5893
6077
  push: webPush({
5894
- publicKey: vapidPublicKey,
5895
- privateKey: vapidPrivateKey,
6078
+ ...vapidKeys,
5896
6079
  subject: process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY,
5897
6080
  onEvent: (message) => console.log(message),
5898
6081
  }),
@@ -5927,7 +6110,7 @@ export async function serve(argv, version = "0.1.0") {
5927
6110
  };
5928
6111
  void follows.audience(event.ownerId).then((audience) => notifyAll(audience, note, {
5929
6112
  ...(eventMail ? { email: resendEmail({ ...eventMail, onEvent: (message) => console.log(message) }) } : {}),
5930
- ...(vapidPublicKey && vapidPrivateKey ? { push: webPush({ publicKey: vapidPublicKey, privateKey: vapidPrivateKey, subject: nixampSite, onEvent: (message) => console.log(message) }) } : {}),
6113
+ ...(vapidKeys ? { push: webPush({ ...vapidKeys, subject: nixampSite, onEvent: (message) => console.log(message) }) } : {}),
5931
6114
  onGone: (endpoint) => follows.removePush(endpoint),
5932
6115
  onEvent: (message) => console.log(message),
5933
6116
  })).catch(() => undefined);
@@ -6249,7 +6432,7 @@ export async function serve(argv, version = "0.1.0") {
6249
6432
  load: (next) => loadSource(tools, next, false),
6250
6433
  tag: (next) => loadTagged(tools, next),
6251
6434
  ...(directory ? { directory } : {}),
6252
- ...(follows ? { follows, vapidPublicKey } : {}),
6435
+ ...(follows ? { follows, vapidKeys } : {}),
6253
6436
  ...(favorites ? { favorites } : {}),
6254
6437
  ...(settingsSync ? { settingsSync } : {}),
6255
6438
  ...(events ? { events, eventWriter: new EventWriter({
@@ -6341,7 +6524,16 @@ export async function serve(argv, version = "0.1.0") {
6341
6524
  // The same pool the follows and reminders use: three small tables in
6342
6525
  // one database do not want three sets of connections.
6343
6526
  ...(pool
6344
- ? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
6527
+ ? {
6528
+ servers: new Servers(pool),
6529
+ handles: new Handles(pool),
6530
+ openDirs: new OpenDirs(pool),
6531
+ accountProfiles: new AccountProfiles(pool),
6532
+ // The school's side of "Connect nixamp": tokens from nixamp.com,
6533
+ // kept against the school account. The issuer is this same
6534
+ // program in production, reached over HTTP like any client.
6535
+ links: new NixampLinks(pool, nixampExchange(nixampSite, BACKTOSCHOOL_CLIENT.id), BACKTOSCHOOL_CLIENT.id),
6536
+ }
6345
6537
  : {}),
6346
6538
  signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
6347
6539
  }
@@ -0,0 +1,9 @@
1
+ import { SpeechError } from "./speech.ts";
2
+ /** Only fixed messages/codes leave this boundary: provider bodies can contain
3
+ * account information. Authorization and billing errors must not be retried
4
+ * as transient 502s by the live player. */
5
+ export declare function voiceProviderFailure(response: Response): Promise<{
6
+ error: SpeechError;
7
+ code: string;
8
+ cooldownMs: number;
9
+ }>;
@@ -0,0 +1,27 @@
1
+ import { SpeechError } from "./speech.js";
2
+ /** Only fixed messages/codes leave this boundary: provider bodies can contain
3
+ * account information. Authorization and billing errors must not be retried
4
+ * as transient 502s by the live player. */
5
+ export async function voiceProviderFailure(response) {
6
+ let code = "unknown";
7
+ try {
8
+ const body = await response.json();
9
+ const status = body?.detail?.status;
10
+ if (typeof status === "string" && ["quota_exceeded", "invalid_api_key", "missing_permissions", "subscription_required", "subscription_expired", "payment_required", "payment_issue", "too_many_concurrent_requests", "rate_limit_exceeded"].includes(status))
11
+ code = status;
12
+ }
13
+ catch { /* Non-JSON provider failures still have an HTTP status. */ }
14
+ if (code === "quota_exceeded")
15
+ return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs credits are exhausted. The site owner needs to check the provider balance.", 402) };
16
+ if (code === "payment_issue")
17
+ return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs blocked transcription because its subscription payment failed. The site owner needs to complete the outstanding ElevenLabs invoice.", 402) };
18
+ if (["subscription_required", "subscription_expired", "payment_required"].includes(code) || response.status === 402)
19
+ return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs billing needs attention. The site owner needs to check the provider subscription.", 402) };
20
+ if (["invalid_api_key", "missing_permissions"].includes(code) || [401, 403].includes(response.status))
21
+ return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs rejected transcription or voice access. The site owner needs to check the provider key, permissions, and billing.", 424) };
22
+ if (response.status === 429) {
23
+ const retry = Number(response.headers.get("retry-after"));
24
+ return { code, cooldownMs: Math.min(300_000, Math.max(30_000, Number.isFinite(retry) ? retry * 1000 : 0)), error: new SpeechError("ElevenLabs is rate limited. Wait before enabling translated audio again.", 429) };
25
+ }
26
+ return { code, cooldownMs: 10_000, error: new SpeechError("ElevenLabs is temporarily unavailable. Try translated audio again shortly.", 502) };
27
+ }
@@ -86,6 +86,14 @@ export declare class WatchParties {
86
86
  */
87
87
  bridge(ownerId: string, origin: PartyOrigin, input: BridgeInput): Promise<PartyView>;
88
88
  byCode(origin: PartyOrigin, partyCode: string): Promise<PartyView | null>;
89
+ /**
90
+ * A party by its code alone, for somebody who has only the code.
91
+ *
92
+ * A code is unique per origin, not across them; a person typing one into
93
+ * nixamp.com is not asked which site it came from. The one still live, or
94
+ * the most recently touched, is the one they mean.
95
+ */
96
+ byAnyCode(partyCode: string): Promise<PartyView | null>;
89
97
  /** The party behind a nixamp room or slug, for a client that has only that. */
90
98
  byEvent(reference: string): Promise<PartyView | null>;
91
99
  /**
@@ -200,6 +200,27 @@ export class WatchParties {
200
200
  return null;
201
201
  return { party: partyFrom(row, event), event };
202
202
  }
203
+ /**
204
+ * A party by its code alone, for somebody who has only the code.
205
+ *
206
+ * A code is unique per origin, not across them; a person typing one into
207
+ * nixamp.com is not asked which site it came from. The one still live, or
208
+ * the most recently touched, is the one they mean.
209
+ */
210
+ async byAnyCode(partyCode) {
211
+ await this.ensure();
212
+ const code = cleanPartyCode(partyCode);
213
+ const { rows } = await this.options.db.query(`SELECT p.* FROM ${TABLE} p JOIN live_events e ON e.id = p.event_id
214
+ WHERE p.party_code = $1
215
+ ORDER BY (e.status = 'live') DESC, p.updated_at DESC LIMIT 1`, [code]);
216
+ const row = rows[0];
217
+ if (!row)
218
+ return null;
219
+ const event = await this.options.events.get(String(row["event_id"] ?? ""));
220
+ if (!event)
221
+ return null;
222
+ return { party: partyFrom(row, event), event };
223
+ }
203
224
  /** The party behind a nixamp room or slug, for a client that has only that. */
204
225
  async byEvent(reference) {
205
226
  await this.ensure();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.37",
3
+ "version": "0.28.41",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,12 +47,12 @@
47
47
  "dependencies": {
48
48
  "@profullstack/auth-system": "^0.6.0",
49
49
  "@profullstack/hqtui": "^0.5.0",
50
+ "@profullstack/notifications": "^0.1.0",
50
51
  "@profullstack/synconfig": "^0.1.2",
51
52
  "@profullstack/throttle": "0.2.2",
52
- "@profullstack/x402-gateway": "0.6.0",
53
+ "@profullstack/x402-gateway": "^0.7.0",
53
54
  "acme-client": "5.4.0",
54
- "pg": "^8.23.0",
55
- "web-push": "^3.6.7"
55
+ "pg": "^8.23.0"
56
56
  },
57
57
  "optionalDependencies": {
58
58
  "@huggingface/transformers": "^4.2.0"
@@ -60,7 +60,6 @@
60
60
  "devDependencies": {
61
61
  "@types/node": "^26",
62
62
  "@types/pg": "^8.23.1",
63
- "@types/web-push": "^3.6.4",
64
63
  "typescript": "^7.0.2"
65
64
  },
66
65
  "trustedDependencies": [
@@ -82,6 +81,7 @@
82
81
  },
83
82
  "overrides": {
84
83
  "https-proxy-agent": "7.0.6",
85
- "tslib": "2.8.1"
84
+ "tslib": "2.8.1",
85
+ "@profullstack/x402-gateway": "^0.7.0"
86
86
  }
87
87
  }
package/src/classroom.ts CHANGED
@@ -16,7 +16,7 @@ export function publicWebUrl(value: unknown): string | null {
16
16
  } catch { return null; }
17
17
  }
18
18
 
19
- export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp"; url: string; embed: string; join: string } | null {
19
+ export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp" | "media"; url: string; embed: string; join: string } | null {
20
20
  const safe = publicWebUrl(value);
21
21
  if (!safe) return null;
22
22
  const url = new URL(safe);
@@ -48,5 +48,9 @@ export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixa
48
48
  player.searchParams.set("play", "live");
49
49
  return classroomBroadcast(player.href);
50
50
  }
51
+ // Direct media is loaded by the browser, never fetched by this server.
52
+ if (/\.(?:m3u8|mp4|webm|mp3|m4a|ogg|wav|ts)$/i.test(url.pathname)) {
53
+ return { provider: "media", url: safe, embed: safe, join: safe };
54
+ }
51
55
  return null;
52
56
  }
package/src/live-api.ts CHANGED
@@ -36,6 +36,12 @@ export interface LiveApiOptions {
36
36
  site?: string;
37
37
  email?: (to: string, note: { title: string; body: string; url: string }) => Promise<boolean>;
38
38
  onEventUpdated?: (event: LiveEvent, previous: LiveEvent) => void;
39
+ /**
40
+ * The account's own card -- name, homepage, photo -- so a class made
41
+ * without them carries the host's instead of nothing. What the class
42
+ * says explicitly still wins.
43
+ */
44
+ hostProfile?: (userId: string) => Promise<{ hostName: string; homepageUrl: string; avatarUrl: string }>;
39
45
  }
40
46
 
41
47
  const CORS = {
@@ -309,6 +315,13 @@ export async function handleLiveApi(
309
315
  const account = await requiredAccount(request, response, options);
310
316
  if (!account) return true;
311
317
  const input = await body(request);
318
+ if (options.hostProfile) {
319
+ const card = await options.hostProfile(account.id);
320
+ for (const key of ["hostName", "homepageUrl", "avatarUrl"] as const) {
321
+ const given = input[key];
322
+ if (card[key] && (given === undefined || given === null || given === "")) input[key] = card[key];
323
+ }
324
+ }
312
325
  const event = await options.events.create(account.id, { ...input, title: input["title"] });
313
326
  json(response, 201, view(event, account));
314
327
  return true;
@@ -307,7 +307,7 @@ function classroomSettings(input: CreateLiveEventInput | UpdateLiveEventInput, c
307
307
  if (key === "hostName") settings[key] = value;
308
308
  else if (key === "broadcastUrl") {
309
309
  const broadcast = classroomBroadcast(value);
310
- if (!broadcast) throw new LiveEventError("Use a Pairux live/join link or a Nixamp Share link with a stream selected. Admin links cannot be shared.", 422);
310
+ if (!broadcast) throw new LiveEventError("Use a Nixamp Share link, a direct HTTPS media link, or a Pairux live/join link. Admin links cannot be shared.", 422);
311
311
  settings[key] = broadcast.url;
312
312
  } else {
313
313
  const safe = publicWebUrl(value);
package/src/live-voice.ts CHANGED
@@ -6,6 +6,7 @@ import type { VoiceProfile } from "./voice-profile.ts";
6
6
  import { Guard } from "./guard.ts";
7
7
  import type { TranslationMeter } from "./translation-passes.ts";
8
8
  import type { Queryable } from "./follows.ts";
9
+ import { voiceProviderFailure } from "./voice-provider.ts";
9
10
 
10
11
  export const LIVE_VOICE_MODEL = "eleven_flash_v2_5";
11
12
  export const LIVE_VOICE_RATE = 16_000;
@@ -37,6 +38,20 @@ export class LiveVoice {
37
38
  private readonly dailyAudioSeconds: number;
38
39
  private readonly userDailyChars: number;
39
40
  private readonly userDailyAudioSeconds: number;
41
+ private providerPause: { until: number; error: SpeechError } | null = null;
42
+
43
+ private checkProvider(): void {
44
+ if (this.providerPause && this.providerPause.until > this.now()) throw this.providerPause.error;
45
+ }
46
+
47
+ private async providerFailed(response: Response, operation: string): Promise<never> {
48
+ const failure = await voiceProviderFailure(response);
49
+ const until = this.now() + failure.cooldownMs;
50
+ // Another in-flight response must not shorten an existing billing pause.
51
+ if (!this.providerPause || this.providerPause.until < until) this.providerPause = { until, error: failure.error };
52
+ console.error(JSON.stringify({ event: "voice_provider_failure", provider: "elevenlabs", operation, status: response.status, code: failure.code, cooldownMs: failure.cooldownMs }));
53
+ throw failure.error;
54
+ }
40
55
 
41
56
  constructor(options: { apiKey?: string; fetcher?: typeof fetch; now?: () => number; charsPerMinute?: number; dailyChars?: number; dailyAudioSeconds?: number; userDailyChars?: number; userDailyAudioSeconds?: number; db?: Queryable; billing?: TranslationMeter } = {}) {
42
57
  this.key = options.apiKey ?? process.env["ELEVENLABS_API_KEY"] ?? "";
@@ -60,6 +75,7 @@ export class LiveVoice {
60
75
  async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
61
76
  if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
62
77
  await meter?.require(by, resource);
78
+ this.checkProvider();
63
79
  const wav = decodeWav(bytes);
64
80
  const seconds = wav.samples.length / wav.rate;
65
81
  if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
@@ -77,6 +93,7 @@ export class LiveVoice {
77
93
  await this.reserve(`scribe:user:${by}`, billed, this.userDailyAudioSeconds, 86_400_000);
78
94
  await this.reserve("scribe:server", billed, this.dailyAudioSeconds, 86_400_000);
79
95
  signal?.throwIfAborted();
96
+ this.checkProvider();
80
97
  const form = new FormData();
81
98
  // Canonical PCM prevents a crafted container from billing more audio
82
99
  // than the duration we validated, and removes uploaded metadata.
@@ -91,8 +108,9 @@ export class LiveVoice {
91
108
  method: "POST", headers: { "xi-api-key": this.key }, body: form,
92
109
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
93
110
  });
94
- if (!answer.ok) throw new SpeechError("speaker transcription could not run; check provider quota and permissions", answer.status === 429 ? 429 : 502);
111
+ if (!answer.ok) await this.providerFailed(answer, "transcription");
95
112
  accepted = true;
113
+ console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "transcription", account: createHash("sha256").update(by).digest("hex").slice(0, 16), audioSeconds: seconds }));
96
114
  if (reservation) await meter!.commit(reservation);
97
115
  return speakerTurns(await answer.json() as ScribeResult, wav);
98
116
  } catch (error) {
@@ -103,12 +121,13 @@ export class LiveVoice {
103
121
 
104
122
  async voices(): Promise<LiveVoiceChoice[]> {
105
123
  if (!this.available()) throw new SpeechError("translated audio needs ELEVENLABS_API_KEY on the account server", 503);
124
+ this.checkProvider();
106
125
  if (!this.catalog || this.now() >= this.catalogUntil) {
107
126
  this.catalogUntil = this.now() + 3600_000;
108
127
  this.catalog = this.fetcher("https://api.elevenlabs.io/v2/voices?page_size=100&voice_type=default", {
109
128
  headers: { "xi-api-key": this.key }, signal: AbortSignal.timeout(8000),
110
129
  }).then(async response => {
111
- if (!response.ok) throw new SpeechError("ElevenLabs could not list voices; check the server key and its voice permissions", 503);
130
+ if (!response.ok) await this.providerFailed(response, "voices");
112
131
  const body = await response.json() as { voices: { voice_id: string; name: string; labels?: Record<string, string> }[] };
113
132
  return body.voices.filter(voice => /^[a-zA-Z0-9_-]+$/.test(voice.voice_id)).map(voice => ({
114
133
  id: voice.voice_id, name: voice.name, gender: voice.labels?.["gender"] ?? "neutral", language: voice.labels?.["language"] ?? "",
@@ -238,6 +257,7 @@ export class LiveVoice {
238
257
  let accepted = false;
239
258
  try {
240
259
  await this.charge(by, ask.channel ?? "direct", text.length);
260
+ this.checkProvider();
241
261
  signal?.throwIfAborted();
242
262
  reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
243
263
  const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
@@ -246,9 +266,11 @@ export class LiveVoice {
246
266
  body: JSON.stringify({ text, model_id: LIVE_VOICE_MODEL, language_code: ask.language }),
247
267
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
248
268
  });
249
- if (!response.ok || !response.body) throw new SpeechError(response.status === 429 ? "ElevenLabs audio quota is temporarily exhausted" : "ElevenLabs could not generate audio; check the server key and quota", response.status === 429 ? 429 : 502);
269
+ if (!response.ok) await this.providerFailed(response, "synthesis");
270
+ if (!response.body) throw new SpeechError("ElevenLabs returned no audio", 502);
250
271
  // Once the provider accepts, aborting playback cannot refund heard audio.
251
272
  accepted = true;
273
+ console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "synthesis", account: createHash("sha256").update(by).digest("hex").slice(0, 16), characters: text.length }));
252
274
  if (reservation) await meter!.commit(reservation);
253
275
  const [play, keep] = response.body.tee();
254
276
  void (async () => {