nixamp 0.78.5 → 0.80.1

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 (107) hide show
  1. package/README.md +6 -5
  2. package/backtoschool/dist/assets/{ar-CYu0byMf.js → ar-BYDVUbTZ.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-BTTM7DeB.js → cs-DsCx7BAy.js} +1 -1
  4. package/backtoschool/dist/assets/{da-iixezQtd.js → da-5tLM7Wal.js} +1 -1
  5. package/backtoschool/dist/assets/{de-Sx3acevR.js → de-2Q8k6IKm.js} +1 -1
  6. package/backtoschool/dist/assets/{es-ByCnw8ja.js → es-CVwFkQ0E.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-BCeFoeH5.js → fi-D7KuvUvL.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-Nz5hMzAE.js → fr-CkWg-nDv.js} +1 -1
  9. package/backtoschool/dist/assets/{hi-x5-IG1rl.js → hi-DayNvhO4.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-OR5FY6YP-O3qhTXm4.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-QqIkGcMe.js → hu-BqS46UyM.js} +1 -1
  12. package/backtoschool/dist/assets/{id-OVwO1Uoi.js → id-DTiKw_BS.js} +1 -1
  13. package/backtoschool/dist/assets/{index-Dgqs6yM_.js → index-EM-0SwBp.js} +3 -3
  14. package/backtoschool/dist/assets/{it-CmssY5Ut.js → it-B60apeGa.js} +1 -1
  15. package/backtoschool/dist/assets/{mpegts-RQlLEU1A.js → mpegts-C4v5WI5W.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-Y2FARLFE-y49QH7a5.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
  17. package/backtoschool/dist/assets/{nl-VkQxlPku.js → nl-DctlIuWS.js} +1 -1
  18. package/backtoschool/dist/assets/{ru-Dk8FAbDx.js → ru-CO8g0gfi.js} +1 -1
  19. package/backtoschool/dist/assets/{sv-CNs1UySx.js → sv-CoFMz-Qg.js} +1 -1
  20. package/backtoschool/dist/assets/{uk-BDB1YayY.js → uk-C3_GprSP.js} +1 -1
  21. package/backtoschool/dist/assets/{vi-DnEIKzy7.js → vi-B0RDG4iN.js} +1 -1
  22. package/backtoschool/dist/assets/{zh-B_atHA4S.js → zh--yO6izcR.js} +1 -1
  23. package/backtoschool/dist/index.html +3 -3
  24. package/dist/ai-keys.d.ts +1 -1
  25. package/dist/ai-keys.js +1 -1
  26. package/dist/ai-label.d.ts +38 -0
  27. package/dist/ai-label.js +237 -5
  28. package/dist/ai-text.d.ts +42 -1
  29. package/dist/ai-text.js +69 -7
  30. package/dist/event-writer.d.ts +10 -1
  31. package/dist/event-writer.js +27 -8
  32. package/dist/label-routes.js +9 -0
  33. package/dist/library-mcp.d.ts +6 -6
  34. package/dist/music-hub.d.ts +11 -0
  35. package/dist/music-hub.js +33 -2
  36. package/dist/nixamp-link-api.d.ts +5 -1
  37. package/dist/nixamp-link-api.js +12 -2
  38. package/dist/nixamp-link-types.d.ts +2 -0
  39. package/dist/nixamp-link.d.ts +22 -9
  40. package/dist/nixamp-link.js +75 -58
  41. package/dist/oauth.d.ts +1 -0
  42. package/dist/oauth.js +8 -1
  43. package/dist/profiles.d.ts +1 -1
  44. package/dist/profiles.js +0 -0
  45. package/dist/server.js +19 -10
  46. package/dist/station-cli.js +24 -0
  47. package/dist/station-mcp.d.ts +6 -6
  48. package/dist/station-usage.d.ts +1 -1
  49. package/dist/station-usage.js +4 -1
  50. package/dist/universal-footer.d.ts +2 -0
  51. package/dist/universal-footer.js +39 -0
  52. package/package.json +7 -5
  53. package/src/ai-keys.ts +1 -1
  54. package/src/ai-label.ts +240 -5
  55. package/src/ai-text.ts +84 -8
  56. package/src/event-writer.ts +36 -11
  57. package/src/label-routes.ts +9 -0
  58. package/src/music-hub.ts +38 -2
  59. package/src/nixamp-link-api.ts +15 -3
  60. package/src/nixamp-link-types.ts +2 -0
  61. package/src/nixamp-link.ts +77 -61
  62. package/src/oauth.ts +9 -1
  63. package/src/profiles.ts +0 -0
  64. package/src/server.ts +19 -10
  65. package/src/station-cli.ts +23 -0
  66. package/src/station-usage.ts +4 -1
  67. package/src/universal-footer.ts +41 -0
  68. package/web/dist/assets/{ar-CqVLKLkR.js → ar-C5qVRZh9.js} +1 -1
  69. package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
  70. package/web/dist/assets/{cs-DwgnZ10A.js → cs-fajZsJrs.js} +1 -1
  71. package/web/dist/assets/{da-C6V-fzX4.js → da-fycV1k1N.js} +1 -1
  72. package/web/dist/assets/{de-CyZ00iAl.js → de-QUzTQ_9X.js} +1 -1
  73. package/web/dist/assets/{es-jlhhpSVu.js → es-CQu2leAJ.js} +1 -1
  74. package/web/dist/assets/{fi-o92ZPZq7.js → fi-AgKEsukC.js} +1 -1
  75. package/web/dist/assets/{fr-BcCMVWa_.js → fr-CRbHnW4y.js} +1 -1
  76. package/web/dist/assets/{hi-Dbgp5pgN.js → hi-BuU7xy9L.js} +1 -1
  77. package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
  78. package/web/dist/assets/{hu-CRa0dBhC.js → hu-C40Z-HcJ.js} +1 -1
  79. package/web/dist/assets/{id-BF7Q2wE4.js → id-2b7626UL.js} +1 -1
  80. package/web/dist/assets/{it-z9MVvK3u.js → it-7sr3nJuS.js} +1 -1
  81. package/web/dist/assets/main-zuI3sOLC.js +33 -0
  82. package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
  83. package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
  84. package/web/dist/assets/{nl-C-9KQwX0.js → nl-BdDGgfzj.js} +1 -1
  85. package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
  86. package/web/dist/assets/{ru-D835Y1dr.js → ru-BMGI8Oa4.js} +1 -1
  87. package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
  88. package/web/dist/assets/station-D6KfgFXf.css +1 -0
  89. package/web/dist/assets/{sv-DLSLSdjL.js → sv-BXASdl7c.js} +1 -1
  90. package/web/dist/assets/{uk-DgCBRDrZ.js → uk-urnhuqtG.js} +1 -1
  91. package/web/dist/assets/{vi-C23GbtRZ.js → vi-BhLPAKuD.js} +1 -1
  92. package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
  93. package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
  94. package/web/dist/assets/{zh-D0dXViuL.js → zh-BaOCIB9u.js} +1 -1
  95. package/web/dist/index.html +58 -12
  96. package/web/dist/music.html +1 -1
  97. package/web/dist/radio.html +5 -5
  98. package/web/dist/station.html +4 -4
  99. package/web/dist/sw.js +30 -30
  100. package/web/dist/vod.html +4 -4
  101. package/web/dist/watch.html +4 -4
  102. package/dist/pit-footer.d.ts +0 -3
  103. package/dist/pit-footer.js +0 -40
  104. package/src/pit-footer.ts +0 -40
  105. package/web/dist/assets/main-Q8gHrr9u.js +0 -26
  106. package/web/dist/assets/station-CxJvFwzV.css +0 -1
  107. /package/web/dist/assets/{chunk-55GBNX64-DR1XNWCJ.css → chunk-NW3WDGEQ-DR1XNWCJ.css} +0 -0
@@ -9,10 +9,8 @@ export declare const LIBRARY_TOOLS: ({
9
9
  name: string;
10
10
  description: string;
11
11
  inputSchema: {
12
- required?: undefined;
13
12
  type: string;
14
13
  properties: {
15
- limit?: undefined;
16
14
  server: {
17
15
  type: "string";
18
16
  description: string;
@@ -25,6 +23,7 @@ export declare const LIBRARY_TOOLS: ({
25
23
  offset?: undefined;
26
24
  kind?: undefined;
27
25
  q?: undefined;
26
+ limit?: undefined;
28
27
  name?: undefined;
29
28
  id?: undefined;
30
29
  items?: undefined;
@@ -32,12 +31,12 @@ export declare const LIBRARY_TOOLS: ({
32
31
  source?: undefined;
33
32
  force?: undefined;
34
33
  };
34
+ required?: undefined;
35
35
  };
36
36
  } | {
37
37
  name: string;
38
38
  description: string;
39
39
  inputSchema: {
40
- required?: undefined;
41
40
  type: string;
42
41
  properties: {
43
42
  server: {
@@ -77,6 +76,7 @@ export declare const LIBRARY_TOOLS: ({
77
76
  source?: undefined;
78
77
  force?: undefined;
79
78
  };
79
+ required?: undefined;
80
80
  };
81
81
  } | {
82
82
  name: string;
@@ -119,7 +119,6 @@ export declare const LIBRARY_TOOLS: ({
119
119
  inputSchema: {
120
120
  type: string;
121
121
  properties: {
122
- limit?: undefined;
123
122
  artist?: undefined;
124
123
  album?: undefined;
125
124
  genre?: undefined;
@@ -128,6 +127,7 @@ export declare const LIBRARY_TOOLS: ({
128
127
  offset?: undefined;
129
128
  kind?: undefined;
130
129
  q?: undefined;
130
+ limit?: undefined;
131
131
  server: {
132
132
  type: "string";
133
133
  description: string;
@@ -154,7 +154,6 @@ export declare const LIBRARY_TOOLS: ({
154
154
  inputSchema: {
155
155
  type: string;
156
156
  properties: {
157
- limit?: undefined;
158
157
  artist?: undefined;
159
158
  album?: undefined;
160
159
  genre?: undefined;
@@ -163,6 +162,7 @@ export declare const LIBRARY_TOOLS: ({
163
162
  offset?: undefined;
164
163
  kind?: undefined;
165
164
  q?: undefined;
165
+ limit?: undefined;
166
166
  name?: undefined;
167
167
  server: {
168
168
  type: "string";
@@ -192,7 +192,6 @@ export declare const LIBRARY_TOOLS: ({
192
192
  required?: undefined;
193
193
  type: string;
194
194
  properties: {
195
- limit?: undefined;
196
195
  artist?: undefined;
197
196
  album?: undefined;
198
197
  genre?: undefined;
@@ -201,6 +200,7 @@ export declare const LIBRARY_TOOLS: ({
201
200
  offset?: undefined;
202
201
  kind?: undefined;
203
202
  q?: undefined;
203
+ limit?: undefined;
204
204
  name?: undefined;
205
205
  id?: undefined;
206
206
  items?: undefined;
@@ -61,7 +61,14 @@ export interface MusicHubOptions {
61
61
  peers: () => string[];
62
62
  fetcher?: typeof fetch;
63
63
  now?: () => number;
64
+ /**
65
+ * Label servers that moved, old https origin to new (a box gone, its label adopted
66
+ * on another): /music addresses under the old origin's id go to the new one's.
67
+ */
68
+ moved?: Record<string, string>;
64
69
  }
70
+ /** NIXAMP_MUSIC_MOVED: "https://old:4321>https://new:4321", several separated by commas. */
71
+ export declare function parseMoved(text: string | undefined): Record<string, string>;
65
72
  export declare class MusicHub {
66
73
  private readonly options;
67
74
  private cache;
@@ -70,6 +77,10 @@ export declare class MusicHub {
70
77
  private now;
71
78
  /** The https origin behind an opaque id, among the servers known now. */
72
79
  resolve(id: string): string;
80
+ /** The servers to gather, less any that moved (the same label would be listed twice). */
81
+ private peers;
82
+ /** The id a moved server goes by now, or "" when this id never moved. */
83
+ movedTo(id: string): string;
73
84
  /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
74
85
  catalogue(): Promise<HubCatalogue>;
75
86
  private read;
package/dist/music-hub.js CHANGED
@@ -23,6 +23,16 @@ export function artistFollowId(name) {
23
23
  const slug = name.split(/\s*;\s*/)[0].normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "artist";
24
24
  return `artist:${slug}`;
25
25
  }
26
+ /** NIXAMP_MUSIC_MOVED: "https://old:4321>https://new:4321", several separated by commas. */
27
+ export function parseMoved(text) {
28
+ const moved = {};
29
+ for (const pair of (text ?? "").split(",")) {
30
+ const [from, to] = pair.split(">").map((one) => one.trim().replace(/\/+$/, ""));
31
+ if (from && to && /^https?:\/\//.test(from) && /^https?:\/\//.test(to) && from !== to)
32
+ moved[from] = to;
33
+ }
34
+ return moved;
35
+ }
26
36
  export class MusicHub {
27
37
  options;
28
38
  cache = null;
@@ -38,10 +48,22 @@ export class MusicHub {
38
48
  if (!/^[a-f0-9]{10}$/.test(id))
39
49
  return "";
40
50
  if (!this.bases.has(id))
41
- for (const base of this.options.peers())
51
+ for (const base of this.peers())
42
52
  this.bases.set(serverId(base), base);
43
53
  return this.bases.get(id) ?? "";
44
54
  }
55
+ /** The servers to gather, less any that moved (the same label would be listed twice). */
56
+ peers() {
57
+ const moved = this.options.moved ?? {};
58
+ return this.options.peers().filter((base) => !moved[base.replace(/\/+$/, "")]);
59
+ }
60
+ /** The id a moved server goes by now, or "" when this id never moved. */
61
+ movedTo(id) {
62
+ for (const [from, to] of Object.entries(this.options.moved ?? {}))
63
+ if (serverId(from) === id)
64
+ return serverId(to);
65
+ return "";
66
+ }
45
67
  /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
46
68
  catalogue() {
47
69
  if (this.cache && this.now() - this.cache.at < 60_000)
@@ -64,7 +86,7 @@ export class MusicHub {
64
86
  }
65
87
  }
66
88
  async gather() {
67
- const peers = [...new Set(this.options.peers())];
89
+ const peers = [...new Set(this.peers())];
68
90
  const holders = new Set();
69
91
  const genres = new Map();
70
92
  const stations = [];
@@ -181,6 +203,15 @@ export async function handleMusicRoutes(request, response, path, ctx) {
181
203
  ctx.json(response, 405, { error: "not like that" });
182
204
  return true;
183
205
  }
206
+ // A label that moved to another server: its old addresses (shared links, search results) go to the new ones.
207
+ const old = /^(\/music\/(?:artist|album|member)\/|\/api\/v1\/music\/(?:file|member)\/)([a-f0-9]{10})(\/.*)$/.exec(path);
208
+ const now = old ? ctx.hub.movedTo?.(old[2]) ?? "" : "";
209
+ if (old && now) {
210
+ const query = (request.url ?? "").includes("?") ? (request.url ?? "").slice((request.url ?? "").indexOf("?")) : "";
211
+ response.writeHead(301, { location: `${old[1]}${now}${old[3]}${query}`, "cache-control": "public, max-age=86400" });
212
+ response.end();
213
+ return true;
214
+ }
184
215
  if (path === "/api/v1/music") {
185
216
  ctx.json(response, 200, await ctx.hub.catalogue());
186
217
  return true;
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * All five want the site's own session: the person, signed in here. What
12
12
  * they hold on nixamp's side is a client token, and it never leaves the
13
- * server -- the page only ever learns a handle and a list of streams.
13
+ * server -- the page only ever learns a handle and a list of streams. The
14
+ * token is kept in memory only, for its hour; never in the database.
14
15
  */
15
16
  import type { IncomingMessage, ServerResponse } from "node:http";
16
17
  import { type Accounts } from "./accounts.ts";
@@ -32,6 +33,9 @@ export interface NixampLinkApiOptions {
32
33
  fetcher?: typeof fetch;
33
34
  }
34
35
  export declare function nixampLinkPath(path: string): boolean;
36
+ /** nixamp.com refused the token held in memory. */
37
+ export declare class StaleToken extends Error {
38
+ }
35
39
  /**
36
40
  * Every server the nixamp account remembers, asked what it is doing.
37
41
  *
@@ -39,6 +39,9 @@ function landing(home, outcome, reason = "") {
39
39
  url.searchParams.set("reason", reason.slice(0, 200));
40
40
  return `${url.pathname}${url.search}${url.hash || "#settings"}`;
41
41
  }
42
+ /** nixamp.com refused the token held in memory. */
43
+ export class StaleToken extends Error {
44
+ }
42
45
  /**
43
46
  * Every server the nixamp account remembers, asked what it is doing.
44
47
  *
@@ -54,8 +57,10 @@ export async function streamsFor(token, issuer, pageSite, fetcher) {
54
57
  headers: { authorization: `Bearer ${token}`, accept: "application/json" },
55
58
  signal: AbortSignal.timeout(8000),
56
59
  });
60
+ if (answer.status === 401)
61
+ throw new StaleToken("nixamp.com no longer accepts this connection; refresh from nixamp");
57
62
  if (!answer.ok)
58
- throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
63
+ throw new Error(`nixamp.com answered ${answer.status}`);
59
64
  const body = (await answer.json().catch(() => ({})));
60
65
  servers = Array.isArray(body.servers) ? body.servers : [];
61
66
  const page = pageSite.replace(/\/+$/, "");
@@ -177,13 +182,18 @@ export async function handleNixampLinkApi(request, response, url, options) {
177
182
  }
178
183
  const token = await options.links.accessToken(account.id);
179
184
  if (token === "") {
180
- json(response, 409, { error: "connect your nixamp account first", connected: false });
185
+ const { connected } = await options.links.of(account.id);
186
+ json(response, 409, connected
187
+ ? { error: "refresh from nixamp to see your streams", connected: true, refresh: true }
188
+ : { error: "connect your nixamp account first", connected: false });
181
189
  return true;
182
190
  }
183
191
  try {
184
192
  json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
185
193
  }
186
194
  catch (error) {
195
+ if (error instanceof StaleToken)
196
+ options.links.drop(account.id);
187
197
  json(response, 502, { error: error.message });
188
198
  }
189
199
  return true;
@@ -5,6 +5,8 @@
5
5
  /** The connection as the account holder sees it. Never the tokens. */
6
6
  export interface LinkView {
7
7
  connected: boolean;
8
+ /** A token is in memory right now, so the streams can be read without asking nixamp.com again. */
9
+ ready: boolean;
8
10
  handle: string;
9
11
  nixampUserId: string;
10
12
  scope: string;
@@ -1,8 +1,11 @@
1
1
  import type { Queryable } from "./follows.ts";
2
2
  import type { LinkView } from "./nixamp-link-types.ts";
3
3
  export type { LinkView } from "./nixamp-link-types.ts";
4
- /** What the school asks nixamp for: who you are, your servers, and to keep it. */
5
- export declare const LINK_SCOPE = "profile streams offline_access";
4
+ /**
5
+ * What the school asks nixamp for: who you are and your servers. Not
6
+ * offline_access: a refresh token would have to be kept somewhere.
7
+ */
8
+ export declare const LINK_SCOPE = "profile streams";
6
9
  export interface TokenGrant {
7
10
  access_token: string;
8
11
  refresh_token?: string;
@@ -32,10 +35,19 @@ export declare class NixampLinks {
32
35
  /** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
33
36
  readonly fetcher: typeof fetch;
34
37
  private ready;
38
+ /** Access tokens, in memory only, until they end. Never in the database. */
39
+ private readonly live;
35
40
  constructor(db: Queryable, exchange: OAuthExchange, clientId: string, now?: () => number,
36
41
  /** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
37
42
  fetcher?: typeof fetch);
38
43
  private ensure;
44
+ /**
45
+ * Before 0.79 the tokens were kept in this table in plain text. Hand each
46
+ * refresh token back to nixamp.com (so no grant is left standing there),
47
+ * then drop the columns. The identity link stays; the person clicks
48
+ * "Refresh from nixamp" the next time they go live.
49
+ */
50
+ private forgetLegacyTokens;
39
51
  /**
40
52
  * The first leg: where to send the browser, and the two secrets the
41
53
  * callback must bring back. The state is the CSRF check, the verifier is
@@ -46,21 +58,22 @@ export declare class NixampLinks {
46
58
  state: string;
47
59
  verifier: string;
48
60
  };
49
- /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
61
+ /** The second leg: the code becomes a token, the token says whose; who is kept, the token only in memory. */
50
62
  finish(userId: string, leg: {
51
63
  code: string;
52
64
  verifier: string;
53
65
  redirectUri: string;
54
66
  }): Promise<LinkView>;
55
- private expiry;
56
67
  of(userId: string): Promise<LinkView>;
68
+ private held;
57
69
  /**
58
- * A token good for a call right now, refreshed when the one kept is about
59
- * to end. "" when there is no connection, or the refresh was refused --
60
- * which is what a grant withdrawn on nixamp.com looks like from here, and
61
- * the row is dropped so the settings page says so too.
70
+ * A token good for a call right now, or "" when there is none in memory:
71
+ * not connected, expired, or the server restarted since. The page then
72
+ * offers "Refresh from nixamp".
62
73
  */
63
74
  accessToken(userId: string): Promise<string>;
64
- /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
75
+ /** nixamp.com refused the token: forget it, so the page asks again. */
76
+ drop(userId: string): void;
77
+ /** Withdraw the token on nixamp.com and forget the connection here. True when there was one. */
65
78
  disconnect(userId: string): Promise<boolean>;
66
79
  }
@@ -9,11 +9,17 @@
9
9
  * broadcast backend a host may plug in, and plugging it in is a choice.
10
10
  *
11
11
  * So the school is an OAuth 2.1 client of nixamp.com, the way bittorrented
12
- * is: PKCE, a consent page on nixamp.com, a refresh token kept here against
13
- * the school account, and a grant the person can withdraw from either side.
14
- * What the connection buys is the list of the servers they run on nixamp and
15
- * what is live on them, so "go live" is a pick from a list rather than a
16
- * link pasted from a terminal.
12
+ * is: PKCE and a consent page on nixamp.com. What the connection buys is the
13
+ * list of the servers they run on nixamp and what is live on them, so "go
14
+ * live" is a pick from a list rather than a link pasted from a terminal.
15
+ *
16
+ * No token is kept at rest (fleet rule, 2026-10-28: nothing a user gave us
17
+ * may be readable by root on the box). The row here is the identity link
18
+ * only: which nixamp account, as which handle. The access token lives in
19
+ * this process's memory until it expires (an hour) and is never written to
20
+ * the database; no refresh token is asked for. When it is gone (expired, or
21
+ * the server restarted) the person, who is present to go live, clicks
22
+ * "Refresh from nixamp" and says yes once more on nixamp.com.
17
23
  *
18
24
  * The client half of OAuth is behind an interface, because the server on the
19
25
  * other end is this same program in production and a stub in a test.
@@ -27,18 +33,18 @@ const SCHEMA = `
27
33
  nixamp_user_id TEXT NOT NULL,
28
34
  handle TEXT NOT NULL DEFAULT '',
29
35
  scope TEXT NOT NULL DEFAULT '',
30
- access_token TEXT NOT NULL DEFAULT '',
31
- access_expires_at TIMESTAMPTZ,
32
- refresh_token TEXT NOT NULL DEFAULT '',
33
36
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
34
37
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
35
38
  );
36
39
  `;
37
- /** What the school asks nixamp for: who you are, your servers, and to keep it. */
38
- export const LINK_SCOPE = "profile streams offline_access";
40
+ /**
41
+ * What the school asks nixamp for: who you are and your servers. Not
42
+ * offline_access: a refresh token would have to be kept somewhere.
43
+ */
44
+ export const LINK_SCOPE = "profile streams";
39
45
  /** How long before an access token's end it is treated as spent. */
40
46
  const EARLY_MS = 60_000;
41
- export const NOT_CONNECTED = { connected: false, handle: "", nixampUserId: "", scope: "", since: null };
47
+ export const NOT_CONNECTED = { connected: false, ready: false, handle: "", nixampUserId: "", scope: "", since: null };
42
48
  /** The real thing: nixamp.com over HTTP. */
43
49
  export function nixampExchange(issuer, clientId, fetcher = fetch) {
44
50
  const base = issuer.replace(/\/+$/, "");
@@ -109,11 +115,12 @@ function asTime(value) {
109
115
  }
110
116
  return null;
111
117
  }
112
- function view(row) {
118
+ function view(row, ready) {
113
119
  if (!row)
114
120
  return { ...NOT_CONNECTED };
115
121
  return {
116
122
  connected: true,
123
+ ready,
117
124
  handle: String(row["handle"] ?? ""),
118
125
  nixampUserId: String(row["nixamp_user_id"] ?? ""),
119
126
  scope: String(row["scope"] ?? ""),
@@ -127,6 +134,8 @@ export class NixampLinks {
127
134
  now;
128
135
  fetcher;
129
136
  ready = null;
137
+ /** Access tokens, in memory only, until they end. Never in the database. */
138
+ live = new Map();
130
139
  constructor(db, exchange, clientId, now = () => Date.now(),
131
140
  /** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */
132
141
  fetcher = fetch) {
@@ -137,9 +146,28 @@ export class NixampLinks {
137
146
  this.fetcher = fetcher;
138
147
  }
139
148
  async ensure() {
140
- this.ready ??= this.db.query(SCHEMA).then(() => undefined);
149
+ this.ready ??= this.db.query(SCHEMA).then(() => this.forgetLegacyTokens());
141
150
  await this.ready;
142
151
  }
152
+ /**
153
+ * Before 0.79 the tokens were kept in this table in plain text. Hand each
154
+ * refresh token back to nixamp.com (so no grant is left standing there),
155
+ * then drop the columns. The identity link stays; the person clicks
156
+ * "Refresh from nixamp" the next time they go live.
157
+ */
158
+ async forgetLegacyTokens() {
159
+ const { rows: columns } = await this.db.query(`SELECT column_name FROM information_schema.columns
160
+ WHERE table_schema = current_schema() AND table_name = '${TABLE}' AND column_name IN ('access_token', 'refresh_token', 'access_expires_at')`);
161
+ if (columns.length === 0)
162
+ return;
163
+ const names = new Set(columns.map((row) => String(row["column_name"])));
164
+ if (names.has("refresh_token")) {
165
+ const { rows } = await this.db.query(`SELECT refresh_token FROM ${TABLE} WHERE refresh_token <> ''`);
166
+ for (const row of rows)
167
+ await this.exchange.revoke(String(row["refresh_token"] ?? "")).catch(() => undefined);
168
+ }
169
+ await this.db.query(`ALTER TABLE ${TABLE} DROP COLUMN IF EXISTS access_token, DROP COLUMN IF EXISTS refresh_token, DROP COLUMN IF EXISTS access_expires_at`);
170
+ }
143
171
  /**
144
172
  * The first leg: where to send the browser, and the two secrets the
145
173
  * callback must bring back. The state is the CSRF check, the verifier is
@@ -159,7 +187,7 @@ export class NixampLinks {
159
187
  });
160
188
  return { url, state, verifier };
161
189
  }
162
- /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
190
+ /** The second leg: the code becomes a token, the token says whose; who is kept, the token only in memory. */
163
191
  async finish(userId, leg) {
164
192
  const grant = await this.exchange.token(new URLSearchParams({
165
193
  grant_type: "authorization_code",
@@ -167,65 +195,54 @@ export class NixampLinks {
167
195
  code_verifier: leg.verifier,
168
196
  redirect_uri: leg.redirectUri,
169
197
  }));
198
+ // Asked for none, but a refresh token sent anyway is handed straight back.
199
+ if (grant.refresh_token)
200
+ await this.exchange.revoke(grant.refresh_token).catch(() => undefined);
170
201
  const who = await this.exchange.userinfo(grant.access_token);
171
202
  await this.ensure();
172
- await this.db.query(`INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token)
173
- VALUES ($1, $2, $3, $4, $5, $6, $7)
203
+ await this.db.query(`INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope)
204
+ VALUES ($1, $2, $3, $4)
174
205
  ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle,
175
- scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at,
176
- refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`, [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""]);
177
- return this.of(userId);
178
- }
179
- expiry(grant) {
206
+ scope = EXCLUDED.scope, updated_at = NOW()`, [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE]);
180
207
  const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600;
181
- return new Date(this.now() + seconds * 1000);
208
+ this.live.set(userId, { token: grant.access_token, until: this.now() + seconds * 1000 });
209
+ return this.of(userId);
182
210
  }
183
211
  async of(userId) {
184
212
  await this.ensure();
185
213
  const { rows } = await this.db.query(`SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`, [userId]);
186
- return view(rows[0]);
214
+ return view(rows[0], rows[0] !== undefined && this.held(userId) !== "");
215
+ }
216
+ held(userId) {
217
+ const kept = this.live.get(userId);
218
+ if (!kept)
219
+ return "";
220
+ if (kept.until - this.now() > EARLY_MS)
221
+ return kept.token;
222
+ this.live.delete(userId);
223
+ return "";
187
224
  }
188
225
  /**
189
- * A token good for a call right now, refreshed when the one kept is about
190
- * to end. "" when there is no connection, or the refresh was refused --
191
- * which is what a grant withdrawn on nixamp.com looks like from here, and
192
- * the row is dropped so the settings page says so too.
226
+ * A token good for a call right now, or "" when there is none in memory:
227
+ * not connected, expired, or the server restarted since. The page then
228
+ * offers "Refresh from nixamp".
193
229
  */
194
230
  async accessToken(userId) {
195
231
  await this.ensure();
196
- const { rows } = await this.db.query(`SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
197
- const row = rows[0];
198
- if (!row)
199
- return "";
200
- const access = String(row["access_token"] ?? "");
201
- const until = asTime(row["access_expires_at"]);
202
- if (access !== "" && until !== null && until - this.now() > EARLY_MS)
203
- return access;
204
- const refresh = String(row["refresh_token"] ?? "");
205
- if (refresh === "")
206
- return "";
207
- let grant;
208
- try {
209
- grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }));
210
- }
211
- catch {
212
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
213
- return "";
214
- }
215
- await this.db.query(`UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`, [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh]);
216
- return grant.access_token;
232
+ return this.held(userId);
233
+ }
234
+ /** nixamp.com refused the token: forget it, so the page asks again. */
235
+ drop(userId) {
236
+ this.live.delete(userId);
217
237
  }
218
- /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
238
+ /** Withdraw the token on nixamp.com and forget the connection here. True when there was one. */
219
239
  async disconnect(userId) {
220
240
  await this.ensure();
221
- const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
222
- const row = rows[0];
223
- if (!row)
224
- return false;
225
- const refresh = String(row["refresh_token"] ?? "");
226
- const access = String(row["access_token"] ?? "");
227
- await this.exchange.revoke(refresh !== "" ? refresh : access);
228
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
229
- return true;
241
+ const token = this.held(userId);
242
+ this.live.delete(userId);
243
+ if (token !== "")
244
+ await this.exchange.revoke(token).catch(() => undefined);
245
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 RETURNING user_id`, [userId]);
246
+ return rows.length > 0;
230
247
  }
231
248
  }
package/dist/oauth.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface Identity {
27
27
  * to an existing account and is never the new account's address.
28
28
  */
29
29
  verified?: boolean;
30
+ openprofileUrl?: string;
30
31
  }
31
32
  /**
32
33
  * CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
package/dist/oauth.js CHANGED
@@ -41,7 +41,14 @@ export function coinpayProvider(clientId, clientSecret, base = "https://coinpayp
41
41
  // Never verified, whatever the answer says: CoinPay sends no mail to the
42
42
  // address it reports, so anybody can register a CoinPay account under
43
43
  // someone else's address. Its `sub` is the whole identity.
44
- return { provider: "coinpay", subject, email, verified: false };
44
+ return {
45
+ provider: "coinpay",
46
+ subject,
47
+ email,
48
+ verified: false,
49
+ // Only when CoinPay names one: an absent field stays absent, not `undefined`.
50
+ ...(typeof who["openprofile_url"] === "string" ? { openprofileUrl: who["openprofile_url"] } : {}),
51
+ };
45
52
  },
46
53
  };
47
54
  }
@@ -22,7 +22,7 @@ export interface HostCard {
22
22
  avatarUrl: string;
23
23
  }
24
24
  export declare const EMPTY_PROFILE: Profile;
25
- /** A photo is a picture, not a document: a megabyte is plenty for a face. */
25
+ /** A photo straight off a phone camera runs several megabytes; ten covers it. */
26
26
  export declare const PHOTO_LIMIT: number;
27
27
  export declare const NAME_LIMIT = 100;
28
28
  export declare const BIO_LIMIT = 1000;
package/dist/profiles.js CHANGED
Binary file