nixamp 0.28.38 → 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 (87) hide show
  1. package/README.md +6 -1
  2. package/backtoschool/dist/assets/{ar-BJrZUvbj.js → ar-BQPKp0M2.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-DlOUfUxw.js → cs-DjJnhGNB.js} +1 -1
  4. package/{web/dist/assets/da-qufG3QK3.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
  5. package/{web/dist/assets/de-BddWhvFB.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
  6. package/backtoschool/dist/assets/{es-DezARIfJ.js → es-BBlBPcq1.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-B_zd8oyM.js → fi-ffo35_4W.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-CMtbCk6s.js → fr-BVvavrk7.js} +1 -1
  9. package/{web/dist/assets/hi-BMTJ6Zea.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-3VKVEQE3-BFn8NctW.js → hls-3VKVEQE3-QZWXCWjR.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-DDqnEZUk.js → hu-BoJj7pZa.js} +1 -1
  12. package/backtoschool/dist/assets/{id-GE_XdNH1.js → id-CXN9gchF.js} +1 -1
  13. package/backtoschool/dist/assets/{index-Bg1kYd8S.css → index-BaiHu9CD.css} +1 -1
  14. package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
  15. package/backtoschool/dist/assets/{it-BbCfCf0u.js → it-C9g0xD6S.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-D6OaCwgB.js → mpegts-GAwbg-pX.js} +1 -1
  17. package/backtoschool/dist/assets/{mpegts-LO6RVLD6-CmKDQL_J.js → mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
  18. package/{web/dist/assets/nl-gqa06B5b.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
  19. package/{web/dist/assets/ru-Bm2pudvY.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
  20. package/backtoschool/dist/assets/{sv-CCLFqo_m.js → sv-DH0ZO3Eh.js} +1 -1
  21. package/{web/dist/assets/uk-CncP4EX7.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
  22. package/backtoschool/dist/assets/{vi-D6DJBpIt.js → vi-D0QPpzbE.js} +1 -1
  23. package/{web/dist/assets/zh-CtcojTCQ.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
  24. package/backtoschool/dist/index.html +59 -8
  25. package/dist/live-api.d.ts +10 -0
  26. package/dist/live-api.js +8 -0
  27. package/dist/live-voice.d.ts +3 -0
  28. package/dist/live-voice.js +27 -4
  29. package/dist/nixamp-link-api.d.ts +44 -0
  30. package/dist/nixamp-link-api.js +190 -0
  31. package/dist/nixamp-link-types.d.ts +33 -0
  32. package/dist/nixamp-link-types.js +5 -0
  33. package/dist/nixamp-link.d.ts +66 -0
  34. package/dist/nixamp-link.js +231 -0
  35. package/dist/notify.d.ts +6 -4
  36. package/dist/notify.js +14 -25
  37. package/dist/oauth-api.d.ts +1 -1
  38. package/dist/oauth-api.js +27 -9
  39. package/dist/oauth-server.d.ts +9 -1
  40. package/dist/oauth-server.js +24 -2
  41. package/dist/profiles.d.ts +85 -0
  42. package/dist/profiles.js +0 -0
  43. package/dist/server.d.ts +12 -2
  44. package/dist/server.js +205 -13
  45. package/dist/voice-provider.d.ts +9 -0
  46. package/dist/voice-provider.js +27 -0
  47. package/dist/watch-party.d.ts +8 -0
  48. package/dist/watch-party.js +21 -0
  49. package/package.json +6 -6
  50. package/src/live-api.ts +13 -0
  51. package/src/live-voice.ts +25 -3
  52. package/src/nixamp-link-api.ts +220 -0
  53. package/src/nixamp-link-types.ts +36 -0
  54. package/src/nixamp-link.ts +265 -0
  55. package/src/notify.ts +19 -34
  56. package/src/oauth-api.ts +30 -11
  57. package/src/oauth-server.ts +24 -2
  58. package/src/profiles.ts +0 -0
  59. package/src/server.ts +192 -15
  60. package/src/voice-provider.ts +22 -0
  61. package/src/watch-party.ts +23 -0
  62. package/web/dist/assets/{ar-tv7nkpiY.js → ar-BqLaLSMR.js} +1 -1
  63. package/web/dist/assets/{cs-D9o3fJ2P.js → cs-CfoQJwKM.js} +1 -1
  64. package/{backtoschool/dist/assets/da-C724NwZt.js → web/dist/assets/da-8MmH23fh.js} +1 -1
  65. package/{backtoschool/dist/assets/de-DGjw5Y71.js → web/dist/assets/de-Brry3OMh.js} +1 -1
  66. package/web/dist/assets/{es-DURiUJ4W.js → es-CGxJs7b8.js} +1 -1
  67. package/web/dist/assets/{fi-D4OG-FzU.js → fi-BDAAZtto.js} +1 -1
  68. package/web/dist/assets/{fr-CYahUXc4.js → fr-9sxYL0Qy.js} +1 -1
  69. package/{backtoschool/dist/assets/hi-B33KN-Pt.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
  70. package/web/dist/assets/{hls-3VKVEQE3-BblrzMwh.js → hls-3VKVEQE3-Dx7qltzA.js} +1 -1
  71. package/web/dist/assets/{hu-Cu0wj9R4.js → hu-CvB9aIyN.js} +1 -1
  72. package/web/dist/assets/{id-DbNfLRpW.js → id-Bmrh06Hw.js} +1 -1
  73. package/web/dist/assets/index-BQuoiKg9.js +3 -0
  74. package/web/dist/assets/{index-JvFXyU1l.css → index-Bo3utcDw.css} +1 -1
  75. package/web/dist/assets/{it-DrOc430y.js → it-SbaxFUIP.js} +1 -1
  76. package/web/dist/assets/{mpegts-D2RCQTRm.js → mpegts-BdwONu8t.js} +1 -1
  77. package/web/dist/assets/{mpegts-LO6RVLD6-SH6yVu0A.js → mpegts-LO6RVLD6-CqW3DT1n.js} +1 -1
  78. package/{backtoschool/dist/assets/nl-CDHaP4Aw.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
  79. package/{backtoschool/dist/assets/ru-DiZnvRs-.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
  80. package/web/dist/assets/{sv-DjYUXKKI.js → sv-BAyIUHP5.js} +1 -1
  81. package/{backtoschool/dist/assets/uk-BtLBQT-n.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
  82. package/web/dist/assets/{vi-B4xhD0GU.js → vi-BdH3SLlR.js} +1 -1
  83. package/{backtoschool/dist/assets/zh-C7GUPWP3.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
  84. package/web/dist/index.html +33 -2
  85. package/web/dist/sw.js +23 -23
  86. package/backtoschool/dist/assets/index-CPUjs97F.js +0 -112
  87. package/web/dist/assets/index-CzW_V6oA.js +0 -3
package/dist/oauth-api.js CHANGED
@@ -121,7 +121,7 @@ function partyBody(parties, view, caller) {
121
121
  party: { ...view.party, positionNow: parties.positionNow(view.party) },
122
122
  event: view.event,
123
123
  links: parties.links(view.party),
124
- host: view.event.ownerId === caller.account.id,
124
+ host: caller !== null && view.event.ownerId === caller.account.id,
125
125
  };
126
126
  }
127
127
  // --- the handler ---------------------------------------------------------------
@@ -282,15 +282,28 @@ export async function handleOAuthApi(request, response, url, options) {
282
282
  return true;
283
283
  }
284
284
  const caller = await callerFor(request, options, "parties");
285
- if (caller === null) {
285
+ const rest = path.slice("/api/v1/watch-parties/".length).split("/");
286
+ const reference = decodeURIComponent(rest[0] ?? "");
287
+ const action = rest[1];
288
+ // Reading one party is open, the way its /live/<slug> page is: the code
289
+ // or the room link IS the invitation, and a person who was handed one
290
+ // on a television or in a terminal has no session on nixamp.com yet.
291
+ // Everything else -- the list, bridging, moving playback, ending --
292
+ // still needs a session or a token with the parties scope.
293
+ const openRead = path !== "/api/v1/watch-parties" && !action && request.method === "GET";
294
+ if (caller === null && !openRead) {
286
295
  json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
287
296
  return true;
288
297
  }
289
298
  // A client bridges parties under its own origin, so two sites cannot
290
299
  // collide on a six-character code; a person acting directly is filed
291
300
  // under nixamp itself.
292
- const origin = caller.clientId || "nixamp";
301
+ const origin = caller?.clientId || "nixamp";
293
302
  if (path === "/api/v1/watch-parties") {
303
+ if (caller === null) {
304
+ json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
305
+ return true;
306
+ }
294
307
  if (request.method === "GET") {
295
308
  const wanted = url.searchParams.get("origin");
296
309
  const found = await parties.list({
@@ -324,14 +337,15 @@ export async function handleOAuthApi(request, response, url, options) {
324
337
  json(response, 405, { error: "GET or POST" });
325
338
  return true;
326
339
  }
327
- const rest = path.slice("/api/v1/watch-parties/".length).split("/");
328
- const reference = decodeURIComponent(rest[0] ?? "");
329
- const action = rest[1];
330
340
  // A party is findable by its code on the origin that bridged it, or by
331
341
  // the nixamp slug or room a client was handed, because a nixamp client
332
- // arriving from a share link has only the latter.
333
- const view = (await parties.byCode(origin, reference).catch(() => null)) ?? (await parties.byEvent(reference));
334
- if (!view) {
342
+ // arriving from a share link has only the latter. A person on
343
+ // nixamp.com with only a code gets the party that code names on
344
+ // whichever site bridged it.
345
+ const view = (await parties.byCode(origin, reference).catch(() => null)) ??
346
+ (await parties.byEvent(reference)) ??
347
+ (caller === null || caller.clientId === "" ? await parties.byAnyCode(reference).catch(() => null) : null);
348
+ if (!view || (caller === null && view.event.visibility === "private")) {
335
349
  json(response, 404, { error: "watch party not found" });
336
350
  return true;
337
351
  }
@@ -339,6 +353,10 @@ export async function handleOAuthApi(request, response, url, options) {
339
353
  json(response, 200, partyBody(parties, view, caller));
340
354
  return true;
341
355
  }
356
+ if (caller === null) {
357
+ json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" });
358
+ return true;
359
+ }
342
360
  if (action === "playback" && request.method === "POST") {
343
361
  let input;
344
362
  try {
@@ -18,6 +18,7 @@ export declare const SCOPES: {
18
18
  readonly profile: "who you are on nixamp (your handle)";
19
19
  readonly email: "the address on your account";
20
20
  readonly parties: "host and join watch parties as you";
21
+ readonly streams: "see the servers you run on nixamp, and what is live on them";
21
22
  readonly offline_access: "stay connected without asking again";
22
23
  };
23
24
  export type Scope = keyof typeof SCOPES;
@@ -35,7 +36,14 @@ export declare const REFRESH_TTL_MS: number;
35
36
  */
36
37
  export declare const BITTORRENTED_CLIENT: OAuthClient;
37
38
  /**
38
- * The registered clients: the built-in one, plus whatever NIXAMP_OAUTH_CLIENTS
39
+ * backtoschool.help runs on this codebase and used to tell its teachers that
40
+ * their school identity *was* a nixamp account. It is not, any more: a school
41
+ * account is a school account, and nixamp is the broadcast backend a host may
42
+ * connect -- through this client, like any other site. Public, PKCE only.
43
+ */
44
+ export declare const BACKTOSCHOOL_CLIENT: OAuthClient;
45
+ /**
46
+ * The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS
39
47
  * names. The variable is a JSON list of `{id, name, redirectUris, secret?,
40
48
  * homepage?}`; an entry with the built-in id replaces it, so a staging
41
49
  * bittorrented can point the callback somewhere else.
@@ -34,6 +34,7 @@ export const SCOPES = {
34
34
  profile: "who you are on nixamp (your handle)",
35
35
  email: "the address on your account",
36
36
  parties: "host and join watch parties as you",
37
+ streams: "see the servers you run on nixamp, and what is live on them",
37
38
  offline_access: "stay connected without asking again",
38
39
  };
39
40
  export const SCOPE_NAMES = Object.keys(SCOPES);
@@ -59,13 +60,32 @@ export const BITTORRENTED_CLIENT = {
59
60
  ],
60
61
  };
61
62
  /**
62
- * The registered clients: the built-in one, plus whatever NIXAMP_OAUTH_CLIENTS
63
+ * backtoschool.help runs on this codebase and used to tell its teachers that
64
+ * their school identity *was* a nixamp account. It is not, any more: a school
65
+ * account is a school account, and nixamp is the broadcast backend a host may
66
+ * connect -- through this client, like any other site. Public, PKCE only.
67
+ */
68
+ export const BACKTOSCHOOL_CLIENT = {
69
+ id: "backtoschool",
70
+ name: "BackToSchool.help",
71
+ homepage: "https://backtoschool.help",
72
+ redirectUris: [
73
+ "https://backtoschool.help/api/v1/nixamp/callback",
74
+ "https://www.backtoschool.help/api/v1/nixamp/callback",
75
+ "http://localhost:5174/api/v1/nixamp/callback",
76
+ ],
77
+ };
78
+ /**
79
+ * The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS
63
80
  * names. The variable is a JSON list of `{id, name, redirectUris, secret?,
64
81
  * homepage?}`; an entry with the built-in id replaces it, so a staging
65
82
  * bittorrented can point the callback somewhere else.
66
83
  */
67
84
  export function clientsFrom(env) {
68
- const byId = new Map([[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT]]);
85
+ const byId = new Map([
86
+ [BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT],
87
+ [BACKTOSCHOOL_CLIENT.id, BACKTOSCHOOL_CLIENT],
88
+ ]);
69
89
  const raw = env["NIXAMP_OAUTH_CLIENTS"];
70
90
  if (raw) {
71
91
  let parsed = [];
@@ -95,6 +115,8 @@ export function clientsFrom(env) {
95
115
  }
96
116
  if (env["NIXAMP_OAUTH_BITTORRENTED"] === "off")
97
117
  byId.delete(BITTORRENTED_CLIENT.id);
118
+ if (env["NIXAMP_OAUTH_BACKTOSCHOOL"] === "off")
119
+ byId.delete(BACKTOSCHOOL_CLIENT.id);
98
120
  return [...byId.values()];
99
121
  }
100
122
  /** Loopback is the address; the port is whatever the CLI got (RFC 8252 §7.3). */
@@ -0,0 +1,85 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ /** The card as the account holder sees it. */
3
+ export interface Profile {
4
+ name: string;
5
+ homepage: string;
6
+ /** A picture somewhere on the web, when they gave one instead of uploading. */
7
+ avatarUrl: string;
8
+ bio: string;
9
+ /** Set when a photo is stored here; the etag changes with the bytes. */
10
+ photo: string;
11
+ }
12
+ export interface ProfileInput {
13
+ name?: unknown;
14
+ homepage?: unknown;
15
+ avatarUrl?: unknown;
16
+ bio?: unknown;
17
+ }
18
+ /** What a classroom shows about its host. */
19
+ export interface HostCard {
20
+ hostName: string;
21
+ homepageUrl: string;
22
+ avatarUrl: string;
23
+ }
24
+ export declare const EMPTY_PROFILE: Profile;
25
+ /** A photo is a picture, not a document: a megabyte is plenty for a face. */
26
+ export declare const PHOTO_LIMIT: number;
27
+ export declare const NAME_LIMIT = 100;
28
+ export declare const BIO_LIMIT = 1000;
29
+ /**
30
+ * The type is read from the bytes, never from the header: a browser sends
31
+ * whatever it was told, and what is served back must be what the file is.
32
+ */
33
+ export declare function sniffImage(bytes: Uint8Array): string;
34
+ /**
35
+ * What may be written. Every field is optional and what is not sent is
36
+ * kept; an empty string clears. A link must be https without credentials,
37
+ * the same rule a classroom applies, so a card never fails at class time.
38
+ */
39
+ export declare function cleanProfile(input: ProfileInput): {
40
+ fields: Partial<Profile>;
41
+ error: string;
42
+ };
43
+ /**
44
+ * The parts of an OpenProfile.md that say who somebody is: the `#` line is
45
+ * the name, the identity block gives `Avatar` and `Web`, the first prose
46
+ * line is the headline, and the first bullet under `## Photos` stands in
47
+ * for a missing Avatar. Everything else is somebody else's business.
48
+ */
49
+ export declare function readOpenProfile(markdown: string): Partial<Profile>;
50
+ /** The three things a classroom shows, from the card, with a stored photo served from here. */
51
+ export declare function hostCard(profile: Profile, userId: string, site: string): HostCard;
52
+ export declare class AccountProfiles {
53
+ private readonly db;
54
+ private readonly fetcher;
55
+ private ready;
56
+ constructor(db: Queryable, fetcher?: typeof fetch);
57
+ private ensure;
58
+ of(userId: string): Promise<Profile>;
59
+ /** Write what was sent, keep what was not. */
60
+ set(userId: string, input: ProfileInput): Promise<{
61
+ profile: Profile | null;
62
+ error: string;
63
+ }>;
64
+ /** Keep a photo. The type comes from the bytes; a file that is not a picture is refused. */
65
+ setPhoto(userId: string, bytes: Uint8Array): Promise<{
66
+ profile: Profile | null;
67
+ error: string;
68
+ }>;
69
+ removePhoto(userId: string): Promise<Profile>;
70
+ /** The stored photo, to serve. Null when there is none. */
71
+ photo(userId: string): Promise<{
72
+ bytes: Uint8Array;
73
+ type: string;
74
+ etag: string;
75
+ } | null>;
76
+ /**
77
+ * Fill the card from an OpenProfile.md. What the file says replaces what
78
+ * is here; what the file does not say is kept. A stored photo stays: an
79
+ * Avatar link is only used where no photo was uploaded.
80
+ */
81
+ importFrom(userId: string, url: string): Promise<{
82
+ profile: Profile | null;
83
+ error: string;
84
+ }>;
85
+ }
Binary file
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
  /**