nixamp 0.78.6 → 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 (104) hide show
  1. package/README.md +6 -5
  2. package/backtoschool/dist/assets/{ar-BBh9pRWn.js → ar-BYDVUbTZ.js} +1 -1
  3. package/backtoschool/dist/assets/{cs-DYDz-hZb.js → cs-DsCx7BAy.js} +1 -1
  4. package/backtoschool/dist/assets/{da-DL0tbrTr.js → da-5tLM7Wal.js} +1 -1
  5. package/backtoschool/dist/assets/{de-POU_MZN1.js → de-2Q8k6IKm.js} +1 -1
  6. package/backtoschool/dist/assets/{es-VLEhXfMp.js → es-CVwFkQ0E.js} +1 -1
  7. package/backtoschool/dist/assets/{fi-B__GWwbQ.js → fi-D7KuvUvL.js} +1 -1
  8. package/backtoschool/dist/assets/{fr-DDtDAMct.js → fr-CkWg-nDv.js} +1 -1
  9. package/backtoschool/dist/assets/{hi-C6MybeLD.js → hi-DayNvhO4.js} +1 -1
  10. package/backtoschool/dist/assets/{hls-OR5FY6YP-CL3tmveA.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
  11. package/backtoschool/dist/assets/{hu-CgiQxTUY.js → hu-BqS46UyM.js} +1 -1
  12. package/backtoschool/dist/assets/{id-pzntv5iA.js → id-DTiKw_BS.js} +1 -1
  13. package/backtoschool/dist/assets/{index-DeCDKrPz.js → index-EM-0SwBp.js} +2 -2
  14. package/backtoschool/dist/assets/{it-dKw8vCrE.js → it-B60apeGa.js} +1 -1
  15. package/backtoschool/dist/assets/{mpegts-B6-Xba87.js → mpegts-C4v5WI5W.js} +1 -1
  16. package/backtoschool/dist/assets/{mpegts-Y2FARLFE-MgjbiVoD.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
  17. package/backtoschool/dist/assets/{nl-DZ0o7967.js → nl-DctlIuWS.js} +1 -1
  18. package/backtoschool/dist/assets/{ru-s6C6TpM6.js → ru-CO8g0gfi.js} +1 -1
  19. package/backtoschool/dist/assets/{sv-BHj0BZlD.js → sv-CoFMz-Qg.js} +1 -1
  20. package/backtoschool/dist/assets/{uk-BH7Y4zb1.js → uk-C3_GprSP.js} +1 -1
  21. package/backtoschool/dist/assets/{vi-nf4HfTQk.js → vi-B0RDG4iN.js} +1 -1
  22. package/backtoschool/dist/assets/{zh-CT2P8jxm.js → zh--yO6izcR.js} +1 -1
  23. package/backtoschool/dist/index.html +1 -1
  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/server.js +18 -9
  44. package/dist/station-cli.js +24 -0
  45. package/dist/station-mcp.d.ts +6 -6
  46. package/dist/station-usage.d.ts +1 -1
  47. package/dist/station-usage.js +4 -1
  48. package/dist/universal-footer.d.ts +2 -0
  49. package/dist/universal-footer.js +39 -0
  50. package/package.json +7 -5
  51. package/src/ai-keys.ts +1 -1
  52. package/src/ai-label.ts +240 -5
  53. package/src/ai-text.ts +84 -8
  54. package/src/event-writer.ts +36 -11
  55. package/src/label-routes.ts +9 -0
  56. package/src/music-hub.ts +38 -2
  57. package/src/nixamp-link-api.ts +15 -3
  58. package/src/nixamp-link-types.ts +2 -0
  59. package/src/nixamp-link.ts +77 -61
  60. package/src/oauth.ts +9 -1
  61. package/src/server.ts +18 -9
  62. package/src/station-cli.ts +23 -0
  63. package/src/station-usage.ts +4 -1
  64. package/src/universal-footer.ts +41 -0
  65. package/web/dist/assets/{ar-7tVGBWgq.js → ar-C5qVRZh9.js} +1 -1
  66. package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
  67. package/web/dist/assets/{cs-hTmkOWFS.js → cs-fajZsJrs.js} +1 -1
  68. package/web/dist/assets/{da-218F21ho.js → da-fycV1k1N.js} +1 -1
  69. package/web/dist/assets/{de-DesLnw7U.js → de-QUzTQ_9X.js} +1 -1
  70. package/web/dist/assets/{es-Bc9fW4qY.js → es-CQu2leAJ.js} +1 -1
  71. package/web/dist/assets/{fi-Df_rWRQA.js → fi-AgKEsukC.js} +1 -1
  72. package/web/dist/assets/{fr-D8xzxUiQ.js → fr-CRbHnW4y.js} +1 -1
  73. package/web/dist/assets/{hi-Ddh5AA1z.js → hi-BuU7xy9L.js} +1 -1
  74. package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
  75. package/web/dist/assets/{hu-ChrJNlJD.js → hu-C40Z-HcJ.js} +1 -1
  76. package/web/dist/assets/{id-dNB_py4J.js → id-2b7626UL.js} +1 -1
  77. package/web/dist/assets/{it-DyHyXE3N.js → it-7sr3nJuS.js} +1 -1
  78. package/web/dist/assets/main-zuI3sOLC.js +33 -0
  79. package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
  80. package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
  81. package/web/dist/assets/{nl-DlwRNqNC.js → nl-BdDGgfzj.js} +1 -1
  82. package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
  83. package/web/dist/assets/{ru--N-8v6QS.js → ru-BMGI8Oa4.js} +1 -1
  84. package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
  85. package/web/dist/assets/station-D6KfgFXf.css +1 -0
  86. package/web/dist/assets/{sv-DbR0DO05.js → sv-BXASdl7c.js} +1 -1
  87. package/web/dist/assets/{uk-Bi3fEoz1.js → uk-urnhuqtG.js} +1 -1
  88. package/web/dist/assets/{vi-DqrcyLEz.js → vi-BhLPAKuD.js} +1 -1
  89. package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
  90. package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
  91. package/web/dist/assets/{zh-_4uBqFzc.js → zh-BaOCIB9u.js} +1 -1
  92. package/web/dist/index.html +58 -12
  93. package/web/dist/music.html +1 -1
  94. package/web/dist/radio.html +5 -5
  95. package/web/dist/station.html +4 -4
  96. package/web/dist/sw.js +30 -30
  97. package/web/dist/vod.html +4 -4
  98. package/web/dist/watch.html +4 -4
  99. package/dist/pit-footer.d.ts +0 -3
  100. package/dist/pit-footer.js +0 -40
  101. package/src/pit-footer.ts +0 -40
  102. package/web/dist/assets/main-D9XA9svM.js +0 -26
  103. package/web/dist/assets/station-CxJvFwzV.css +0 -1
  104. /package/web/dist/assets/{chunk-55GBNX64-DR1XNWCJ.css → chunk-NW3WDGEQ-DR1XNWCJ.css} +0 -0
package/src/ai-text.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * AI radio asks for small things: a title and a prompt for the next track, a
5
5
  * genre and a verdict for a shared file. Whichever model has a key answers --
6
- * Gemini, then OpenAI, then Anthropic -- and when none has, the caller is told
7
- * so and does without (a rule of its own, or a person). The answer is parsed
8
- * and handed back as an object; a model that rambles round its JSON is read
9
- * for the first object in what it said.
6
+ * c0mpute, then Gemini, then OpenAI, then Anthropic -- and when none has, the
7
+ * caller is told so and does without (a rule of its own, or a person). The
8
+ * answer is parsed and handed back as an object; a model that rambles round
9
+ * its JSON is read for the first object in what it said.
10
10
  */
11
+ import { ProviderError, createAiProviders, type AiRouter } from "@profullstack/ai-providers";
11
12
  import { aiKey } from "./ai-keys.ts";
12
13
 
13
14
  export interface AskOptions {
@@ -15,7 +16,7 @@ export interface AskOptions {
15
16
  system: string;
16
17
  /** The question, with what it should answer from. */
17
18
  prompt: string;
18
- /** Milliseconds before giving up (default 30 s). */
19
+ /** Milliseconds before giving up on a hosted model (default 30 s); c0mpute gets at least C0MPUTE_MIN_WAIT_MS. */
19
20
  timeoutMs?: number;
20
21
  /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
21
22
  maxTokens?: number;
@@ -33,7 +34,7 @@ export interface Answer {
33
34
 
34
35
  export class NoModel extends Error {
35
36
  constructor() {
36
- super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
37
+ super("no language model key is set (C0MPUTE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
37
38
  }
38
39
  }
39
40
 
@@ -61,7 +62,7 @@ export function escapeInStrings(text: string): string {
61
62
 
62
63
  /** The first JSON object in a model's answer. */
63
64
  export function firstObject(text: string): Record<string, unknown> {
64
- const fenced = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
65
+ const fenced = text.replace(/<think>[\s\S]*?<\/think>/gi, "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
65
66
  const cleaned = (() => {
66
67
  try { JSON.parse(fenced); return fenced; } catch { return escapeInStrings(fenced); }
67
68
  })();
@@ -80,14 +81,79 @@ export function firstObject(text: string): Record<string, unknown> {
80
81
  throw new Error("the model did not answer with a JSON object");
81
82
  }
82
83
 
84
+ /** The c0mpute key, or "" when there is none or C0MPUTE_DISABLED turns it off. */
85
+ export function c0mputeKey(env: Record<string, string | undefined> = process.env): string {
86
+ return /^(1|true|yes|on)$/i.test(env["C0MPUTE_DISABLED"] ?? "") ? "" : aiKey("C0MPUTE_API_KEY", env);
87
+ }
88
+
83
89
  /** Whether any model can be asked. */
84
90
  export function hasModel(env: Record<string, string | undefined> = process.env): boolean {
85
- return Boolean(aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
91
+ return Boolean(c0mputeKey(env) || aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
92
+ }
93
+
94
+ /**
95
+ * c0mpute's 9B model takes 30 s for a short JSON answer, longer on a busy
96
+ * node, so it never gets a hosted model's 30 s budget.
97
+ */
98
+ export const C0MPUTE_MIN_WAIT_MS = 120_000;
99
+
100
+ /** c0mpute alone: the fallbacks are this file's own chain, not the package's. */
101
+ export function c0mputeRouter(key: string, options: { env?: Record<string, string | undefined>; fetcher?: typeof fetch; concurrency?: number } = {}): AiRouter {
102
+ return createAiProviders({
103
+ env: { ...(options.env ?? process.env), C0MPUTE_API_KEY: key },
104
+ paidFallback: false,
105
+ c0mpute: {
106
+ ...(options.fetcher ? { fetch: options.fetcher } : {}),
107
+ ...(options.concurrency ? { concurrency: options.concurrency } : {}),
108
+ },
109
+ });
110
+ }
111
+
112
+ export interface C0mputeAsk {
113
+ system: string;
114
+ user: string;
115
+ maxTokens: number;
116
+ timeoutMs: number;
117
+ temperature?: number;
118
+ signal?: AbortSignal;
119
+ }
120
+
121
+ /**
122
+ * One answer from c0mpute, read by `read`. An answer `read` refuses gets one
123
+ * more try on the same model, told what was wrong with it, before the caller
124
+ * moves on: a small model misses far more often than a hosted one.
125
+ */
126
+ export async function askC0mpute<T>(router: AiRouter, ask: C0mputeAsk, read: (text: string) => T): Promise<{ model: string; value: T }> {
127
+ const { value } = await router.run(async (provider) => {
128
+ const first = await provider.generate(ask);
129
+ try {
130
+ return { model: first.model, value: read(first.text) };
131
+ } catch (error) {
132
+ const again = await provider.generate({
133
+ ...ask,
134
+ user: `${ask.user}\n\nYour previous answer could not be used: ${(error as Error).message}\nAnswer again with only the corrected JSON object.`,
135
+ });
136
+ try {
137
+ return { model: again.model, value: read(again.text) };
138
+ } catch (second) {
139
+ throw new ProviderError("c0mpute", (second as Error).message, { kind: "output" });
140
+ }
141
+ }
142
+ });
143
+ return value;
144
+ }
145
+
146
+ /** The router askJson shares across calls, so a dead or busy c0mpute is parked for all of them. */
147
+ let shared: { key: string; router: AiRouter } | undefined;
148
+ function sharedRouter(key: string, env: Record<string, string | undefined>): AiRouter {
149
+ if (shared?.key !== key) shared = { key, router: c0mputeRouter(key, { env }) };
150
+ return shared.router;
86
151
  }
87
152
 
88
153
  export async function askJson(options: AskOptions): Promise<Answer> {
89
154
  const env = options.env ?? process.env;
90
155
  const send = options.fetcher ?? fetch;
156
+ const c0mpute = c0mputeKey(env);
91
157
  const gemini = aiKey("GEMINI_API_KEY", env);
92
158
  const openai = aiKey("OPENAI_API_KEY", env);
93
159
  const anthropic = aiKey("ANTHROPIC_API_KEY", env);
@@ -95,6 +161,16 @@ export async function askJson(options: AskOptions): Promise<Answer> {
95
161
  // answers 400 "User location is not supported" from some networks) or is
96
162
  // down is not a reason to go without when another can answer.
97
163
  const tries: Array<() => Promise<Answer>> = [];
164
+ if (c0mpute) tries.push(() => askC0mpute(
165
+ options.fetcher ? c0mputeRouter(c0mpute, { env, fetcher: options.fetcher }) : sharedRouter(c0mpute, env),
166
+ {
167
+ system: `${options.system}\nAnswer with one JSON object and nothing else.`,
168
+ user: options.prompt,
169
+ maxTokens: options.maxTokens ?? 1024,
170
+ timeoutMs: Math.max(options.timeoutMs ?? 0, C0MPUTE_MIN_WAIT_MS),
171
+ },
172
+ firstObject,
173
+ ));
98
174
  if (gemini) tries.push(async () => {
99
175
  const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
100
176
  const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
@@ -1,10 +1,13 @@
1
+ import { jsonInstructions, type AiRouter } from "@profullstack/ai-providers";
2
+ import { askC0mpute, c0mputeRouter, C0MPUTE_MIN_WAIT_MS, firstObject } from "./ai-text.ts";
1
3
  import { Guard } from "./guard.ts";
2
4
 
3
5
  export interface EventDraft { title: string; description: string; topic: string }
4
6
  export interface WriterInput extends EventDraft {
5
7
  hostName: string; startsAt: string; timezone: string; duration: string; recurrence: string;
6
8
  }
7
- export interface EventDraftResult { draft: EventDraft; provider: "openai" | "claude" }
9
+ type Provider = "c0mpute" | "openai" | "claude";
10
+ export interface EventDraftResult { draft: EventDraft; provider: Provider }
8
11
  export class EventWriterError extends Error {
9
12
  constructor(message: string, readonly status: number, readonly retryAfter?: number) { super(message); }
10
13
  }
@@ -37,8 +40,7 @@ Write in the language of the host's notes. Describe what students will learn and
37
40
  Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
38
41
  The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
39
42
 
40
- function draftFrom(text: string): EventDraft {
41
- const value: unknown = JSON.parse(text);
43
+ function draftOf(value: unknown): EventDraft {
42
44
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid draft");
43
45
  const row = value as Record<string, unknown>;
44
46
  const draft = {} as EventDraft;
@@ -51,34 +53,46 @@ function draftFrom(text: string): EventDraft {
51
53
  }
52
54
 
53
55
  export interface EventWriterOptions {
54
- openaiKey?: string; anthropicKey?: string;
56
+ c0mputeKey?: string; openaiKey?: string; anthropicKey?: string;
55
57
  openaiModel?: string; claudeModel?: string;
56
58
  fetch?: typeof globalThis.fetch;
59
+ /** Per hosted provider (default 25 s). */
57
60
  timeoutMs?: number;
61
+ /** For c0mpute, which needs longer (default C0MPUTE_MIN_WAIT_MS). */
62
+ c0mputeTimeoutMs?: number;
58
63
  }
59
64
 
60
65
  /** Server-side only. Drafting has no access to the event store and cannot publish. */
61
66
  export class EventWriter {
62
67
  private readonly guard = new Guard();
63
68
  private readonly active = new Set<string>();
64
- constructor(private readonly options: EventWriterOptions) {}
69
+ // Its own router: a cancelled draft must not park c0mpute for AI radio, and
70
+ // a draft someone is waiting for must not queue behind a label's album.
71
+ private readonly c0mpute?: AiRouter;
72
+ constructor(private readonly options: EventWriterOptions) {
73
+ if (options.c0mputeKey) this.c0mpute = c0mputeRouter(options.c0mputeKey, { concurrency: 4, ...(options.fetch ? { fetcher: options.fetch } : {}) });
74
+ }
75
+
76
+ private key(provider: Exclude<Provider, "c0mpute">): string | undefined {
77
+ return provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
78
+ }
65
79
 
66
80
  async draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult> {
67
81
  if (!accountId) throw new EventWriterError("Sign in to use the AI writer.", 401);
68
82
  const input = writerInput(value);
69
- if (!this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
83
+ if (!this.c0mpute && !this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
70
84
  if (this.active.has(accountId)) throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
71
85
  if (this.active.size >= 4) throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
72
86
  const limit = this.guard.check(accountId, {allowed: 6, windowMs: 10 * 60_000});
73
87
  if (!limit.ok) throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
74
88
  this.active.add(accountId);
75
89
  try {
76
- for (const provider of ["openai", "claude"] as const) {
77
- const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
78
- if (!key) continue;
90
+ for (const provider of ["c0mpute", "openai", "claude"] as const) {
91
+ const key = provider === "c0mpute" ? "" : this.key(provider);
92
+ if (provider === "c0mpute" ? !this.c0mpute : !key) continue;
79
93
  if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
80
94
  try {
81
- const draft = await this.generate(provider, key, input, signal);
95
+ const draft = provider === "c0mpute" ? await this.generateC0mpute(input, signal) : await this.generate(provider, key as string, input, signal);
82
96
  if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
83
97
  return {draft, provider};
84
98
  }
@@ -94,6 +108,17 @@ export class EventWriter {
94
108
  } finally { this.active.delete(accountId); }
95
109
  }
96
110
 
111
+ private async generateC0mpute(input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
112
+ const {value} = await askC0mpute(this.c0mpute as AiRouter, {
113
+ system: `${INSTRUCTIONS}\n\n${jsonInstructions("event_draft", SCHEMA)}`,
114
+ user: JSON.stringify(input),
115
+ maxTokens: 2048,
116
+ timeoutMs: this.options.c0mputeTimeoutMs ?? C0MPUTE_MIN_WAIT_MS,
117
+ ...(signal ? {signal} : {}),
118
+ }, text => draftOf(firstObject(text)));
119
+ return value;
120
+ }
121
+
97
122
  private async generate(provider: "openai" | "claude", key: string, input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
98
123
  const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
99
124
  const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -121,6 +146,6 @@ export class EventWriter {
121
146
  if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal")) throw new Refused();
122
147
  if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn") throw new Error("incomplete draft");
123
148
  const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
124
- return draftFrom(text);
149
+ return draftOf(JSON.parse(text));
125
150
  }
126
151
  }
@@ -202,6 +202,15 @@ export async function handleLabelRoutes(
202
202
  ctx.json(response, 202, label.status());
203
203
  return true;
204
204
  }
205
+ if (path === "/api/v1/stations/label/adopt" && method === "POST") {
206
+ // Albums already on disk (copied from another server) back on the label, with their old keys where given.
207
+ const input = await jsonBody(ctx, request);
208
+ const record = (value: unknown): Record<string, string> => value && typeof value === "object" && !Array.isArray(value)
209
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).filter(([, one]) => typeof one === "string")) as Record<string, string>
210
+ : {};
211
+ ctx.json(response, 200, { adopted: await label.adopt({ keys: record(input["keys"]), albums: record(input["albums"]), enabled: input["enabled"] !== false }) });
212
+ return true;
213
+ }
205
214
  if (path === "/api/v1/stations/label/artists" && method === "POST") {
206
215
  const input = await jsonBody(ctx, request);
207
216
  if (input["roster"] === true) {
package/src/music-hub.ts CHANGED
@@ -45,6 +45,21 @@ export interface MusicHubOptions {
45
45
  peers: () => string[];
46
46
  fetcher?: typeof fetch;
47
47
  now?: () => number;
48
+ /**
49
+ * Label servers that moved, old https origin to new (a box gone, its label adopted
50
+ * on another): /music addresses under the old origin's id go to the new one's.
51
+ */
52
+ moved?: Record<string, string>;
53
+ }
54
+
55
+ /** NIXAMP_MUSIC_MOVED: "https://old:4321>https://new:4321", several separated by commas. */
56
+ export function parseMoved(text: string | undefined): Record<string, string> {
57
+ const moved: Record<string, string> = {};
58
+ for (const pair of (text ?? "").split(",")) {
59
+ const [from, to] = pair.split(">").map((one) => one.trim().replace(/\/+$/, ""));
60
+ if (from && to && /^https?:\/\//.test(from) && /^https?:\/\//.test(to) && from !== to) moved[from] = to;
61
+ }
62
+ return moved;
48
63
  }
49
64
 
50
65
  export class MusicHub {
@@ -60,10 +75,22 @@ export class MusicHub {
60
75
  /** The https origin behind an opaque id, among the servers known now. */
61
76
  resolve(id: string): string {
62
77
  if (!/^[a-f0-9]{10}$/.test(id)) return "";
63
- if (!this.bases.has(id)) for (const base of this.options.peers()) this.bases.set(serverId(base), base);
78
+ if (!this.bases.has(id)) for (const base of this.peers()) this.bases.set(serverId(base), base);
64
79
  return this.bases.get(id) ?? "";
65
80
  }
66
81
 
82
+ /** The servers to gather, less any that moved (the same label would be listed twice). */
83
+ private peers(): string[] {
84
+ const moved = this.options.moved ?? {};
85
+ return this.options.peers().filter((base) => !moved[base.replace(/\/+$/, "")]);
86
+ }
87
+
88
+ /** The id a moved server goes by now, or "" when this id never moved. */
89
+ movedTo(id: string): string {
90
+ for (const [from, to] of Object.entries(this.options.moved ?? {})) if (serverId(from) === id) return serverId(to);
91
+ return "";
92
+ }
93
+
67
94
  /** The catalogue, gathered at most once a minute (a slow or gone server is left out, not waited on). */
68
95
  catalogue(): Promise<HubCatalogue> {
69
96
  if (this.cache && this.now() - this.cache.at < 60_000) return this.cache.value;
@@ -85,7 +112,7 @@ export class MusicHub {
85
112
  }
86
113
 
87
114
  private async gather(): Promise<HubCatalogue> {
88
- const peers = [...new Set(this.options.peers())];
115
+ const peers = [...new Set(this.peers())];
89
116
  const holders = new Set<string>();
90
117
  const genres = new Map<string, { genre: string; stations: HubStation[]; artists: HubArtist[] }>();
91
118
  const stations: HubStation[] = [];
@@ -192,6 +219,15 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
192
219
  return true;
193
220
  }
194
221
  if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "not like that" }); return true; }
222
+ // A label that moved to another server: its old addresses (shared links, search results) go to the new ones.
223
+ const old = /^(\/music\/(?:artist|album|member)\/|\/api\/v1\/music\/(?:file|member)\/)([a-f0-9]{10})(\/.*)$/.exec(path);
224
+ const now = old ? ctx.hub.movedTo?.(old[2]!) ?? "" : "";
225
+ if (old && now) {
226
+ const query = (request.url ?? "").includes("?") ? (request.url ?? "").slice((request.url ?? "").indexOf("?")) : "";
227
+ response.writeHead(301, { location: `${old[1]}${now}${old[3]}${query}`, "cache-control": "public, max-age=86400" });
228
+ response.end();
229
+ return true;
230
+ }
195
231
  if (path === "/api/v1/music") {
196
232
  ctx.json(response, 200, await ctx.hub.catalogue());
197
233
  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 { tokenFrom, type Accounts } from "./accounts.ts";
@@ -77,6 +78,9 @@ function landing(home: string, outcome: string, reason = ""): string {
77
78
  return `${url.pathname}${url.search}${url.hash || "#settings"}`;
78
79
  }
79
80
 
81
+ /** nixamp.com refused the token held in memory. */
82
+ export class StaleToken extends Error {}
83
+
80
84
  /**
81
85
  * Every server the nixamp account remembers, asked what it is doing.
82
86
  *
@@ -92,7 +96,8 @@ export async function streamsFor(token: string, issuer: string, pageSite: string
92
96
  headers: { authorization: `Bearer ${token}`, accept: "application/json" },
93
97
  signal: AbortSignal.timeout(8000),
94
98
  });
95
- if (!answer.ok) throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`);
99
+ if (answer.status === 401) throw new StaleToken("nixamp.com no longer accepts this connection; refresh from nixamp");
100
+ if (!answer.ok) throw new Error(`nixamp.com answered ${answer.status}`);
96
101
  const body = (await answer.json().catch(() => ({}))) as { servers?: typeof servers };
97
102
  servers = Array.isArray(body.servers) ? body.servers : [];
98
103
  const page = pageSite.replace(/\/+$/, "");
@@ -210,10 +215,17 @@ export async function handleNixampLinkApi(
210
215
  // /api/v1/nixamp/streams
211
216
  if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; }
212
217
  const token = await options.links.accessToken(account.id);
213
- if (token === "") { json(response, 409, { error: "connect your nixamp account first", connected: false }); return true; }
218
+ if (token === "") {
219
+ const { connected } = await options.links.of(account.id);
220
+ json(response, 409, connected
221
+ ? { error: "refresh from nixamp to see your streams", connected: true, refresh: true }
222
+ : { error: "connect your nixamp account first", connected: false });
223
+ return true;
224
+ }
214
225
  try {
215
226
  json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) });
216
227
  } catch (error) {
228
+ if (error instanceof StaleToken) options.links.drop(account.id);
217
229
  json(response, 502, { error: (error as Error).message });
218
230
  }
219
231
  return true;
@@ -6,6 +6,8 @@
6
6
  /** The connection as the account holder sees it. Never the tokens. */
7
7
  export interface LinkView {
8
8
  connected: boolean;
9
+ /** A token is in memory right now, so the streams can be read without asking nixamp.com again. */
10
+ ready: boolean;
9
11
  handle: string;
10
12
  nixampUserId: string;
11
13
  scope: string;
@@ -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.
@@ -32,16 +38,16 @@ const SCHEMA = `
32
38
  nixamp_user_id TEXT NOT NULL,
33
39
  handle TEXT NOT NULL DEFAULT '',
34
40
  scope TEXT NOT NULL DEFAULT '',
35
- access_token TEXT NOT NULL DEFAULT '',
36
- access_expires_at TIMESTAMPTZ,
37
- refresh_token TEXT NOT NULL DEFAULT '',
38
41
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39
42
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
40
43
  );
41
44
  `;
42
45
 
43
- /** What the school asks nixamp for: who you are, your servers, and to keep it. */
44
- export const LINK_SCOPE = "profile streams offline_access";
46
+ /**
47
+ * What the school asks nixamp for: who you are and your servers. Not
48
+ * offline_access: a refresh token would have to be kept somewhere.
49
+ */
50
+ export const LINK_SCOPE = "profile streams";
45
51
 
46
52
  /** How long before an access token's end it is treated as spent. */
47
53
  const EARLY_MS = 60_000;
@@ -67,7 +73,7 @@ export interface OAuthExchange {
67
73
  revoke(token: string): Promise<void>;
68
74
  }
69
75
 
70
- export const NOT_CONNECTED: LinkView = { connected: false, handle: "", nixampUserId: "", scope: "", since: null };
76
+ export const NOT_CONNECTED: LinkView = { connected: false, ready: false, handle: "", nixampUserId: "", scope: "", since: null };
71
77
 
72
78
  /** The real thing: nixamp.com over HTTP. */
73
79
  export function nixampExchange(issuer: string, clientId: string, fetcher: typeof fetch = fetch): OAuthExchange {
@@ -134,10 +140,11 @@ function asTime(value: unknown): number | null {
134
140
  return null;
135
141
  }
136
142
 
137
- function view(row: Record<string, unknown> | undefined): LinkView {
143
+ function view(row: Record<string, unknown> | undefined, ready: boolean): LinkView {
138
144
  if (!row) return { ...NOT_CONNECTED };
139
145
  return {
140
146
  connected: true,
147
+ ready,
141
148
  handle: String(row["handle"] ?? ""),
142
149
  nixampUserId: String(row["nixamp_user_id"] ?? ""),
143
150
  scope: String(row["scope"] ?? ""),
@@ -147,6 +154,8 @@ function view(row: Record<string, unknown> | undefined): LinkView {
147
154
 
148
155
  export class NixampLinks {
149
156
  private ready: Promise<void> | null = null;
157
+ /** Access tokens, in memory only, until they end. Never in the database. */
158
+ private readonly live = new Map<string, { token: string; until: number }>();
150
159
 
151
160
  constructor(
152
161
  private readonly db: Queryable,
@@ -158,10 +167,32 @@ export class NixampLinks {
158
167
  ) {}
159
168
 
160
169
  private async ensure(): Promise<void> {
161
- this.ready ??= this.db.query(SCHEMA).then(() => undefined);
170
+ this.ready ??= this.db.query(SCHEMA).then(() => this.forgetLegacyTokens());
162
171
  await this.ready;
163
172
  }
164
173
 
174
+ /**
175
+ * Before 0.79 the tokens were kept in this table in plain text. Hand each
176
+ * refresh token back to nixamp.com (so no grant is left standing there),
177
+ * then drop the columns. The identity link stays; the person clicks
178
+ * "Refresh from nixamp" the next time they go live.
179
+ */
180
+ private async forgetLegacyTokens(): Promise<void> {
181
+ const { rows: columns } = await this.db.query(
182
+ `SELECT column_name FROM information_schema.columns
183
+ WHERE table_schema = current_schema() AND table_name = '${TABLE}' AND column_name IN ('access_token', 'refresh_token', 'access_expires_at')`,
184
+ );
185
+ if (columns.length === 0) return;
186
+ const names = new Set(columns.map((row) => String(row["column_name"])));
187
+ if (names.has("refresh_token")) {
188
+ const { rows } = await this.db.query(`SELECT refresh_token FROM ${TABLE} WHERE refresh_token <> ''`);
189
+ for (const row of rows) await this.exchange.revoke(String(row["refresh_token"] ?? "")).catch(() => undefined);
190
+ }
191
+ await this.db.query(
192
+ `ALTER TABLE ${TABLE} DROP COLUMN IF EXISTS access_token, DROP COLUMN IF EXISTS refresh_token, DROP COLUMN IF EXISTS access_expires_at`,
193
+ );
194
+ }
195
+
165
196
  /**
166
197
  * The first leg: where to send the browser, and the two secrets the
167
198
  * callback must bring back. The state is the CSRF check, the verifier is
@@ -182,7 +213,7 @@ export class NixampLinks {
182
213
  return { url, state, verifier };
183
214
  }
184
215
 
185
- /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */
216
+ /** The second leg: the code becomes a token, the token says whose; who is kept, the token only in memory. */
186
217
  async finish(userId: string, leg: { code: string; verifier: string; redirectUri: string }): Promise<LinkView> {
187
218
  const grant = await this.exchange.token(new URLSearchParams({
188
219
  grant_type: "authorization_code",
@@ -190,22 +221,20 @@ export class NixampLinks {
190
221
  code_verifier: leg.verifier,
191
222
  redirect_uri: leg.redirectUri,
192
223
  }));
224
+ // Asked for none, but a refresh token sent anyway is handed straight back.
225
+ if (grant.refresh_token) await this.exchange.revoke(grant.refresh_token).catch(() => undefined);
193
226
  const who = await this.exchange.userinfo(grant.access_token);
194
227
  await this.ensure();
195
228
  await this.db.query(
196
- `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token)
197
- VALUES ($1, $2, $3, $4, $5, $6, $7)
229
+ `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope)
230
+ VALUES ($1, $2, $3, $4)
198
231
  ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle,
199
- scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at,
200
- refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`,
201
- [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""],
232
+ scope = EXCLUDED.scope, updated_at = NOW()`,
233
+ [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE],
202
234
  );
203
- return this.of(userId);
204
- }
205
-
206
- private expiry(grant: TokenGrant): Date {
207
235
  const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600;
208
- return new Date(this.now() + seconds * 1000);
236
+ this.live.set(userId, { token: grant.access_token, until: this.now() + seconds * 1000 });
237
+ return this.of(userId);
209
238
  }
210
239
 
211
240
  async of(userId: string): Promise<LinkView> {
@@ -214,52 +243,39 @@ export class NixampLinks {
214
243
  `SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`,
215
244
  [userId],
216
245
  );
217
- return view(rows[0]);
246
+ return view(rows[0], rows[0] !== undefined && this.held(userId) !== "");
247
+ }
248
+
249
+ private held(userId: string): string {
250
+ const kept = this.live.get(userId);
251
+ if (!kept) return "";
252
+ if (kept.until - this.now() > EARLY_MS) return kept.token;
253
+ this.live.delete(userId);
254
+ return "";
218
255
  }
219
256
 
220
257
  /**
221
- * A token good for a call right now, refreshed when the one kept is about
222
- * to end. "" when there is no connection, or the refresh was refused --
223
- * which is what a grant withdrawn on nixamp.com looks like from here, and
224
- * the row is dropped so the settings page says so too.
258
+ * A token good for a call right now, or "" when there is none in memory:
259
+ * not connected, expired, or the server restarted since. The page then
260
+ * offers "Refresh from nixamp".
225
261
  */
226
262
  async accessToken(userId: string): Promise<string> {
227
263
  await this.ensure();
228
- const { rows } = await this.db.query(
229
- `SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`,
230
- [userId],
231
- );
232
- const row = rows[0];
233
- if (!row) return "";
234
- const access = String(row["access_token"] ?? "");
235
- const until = asTime(row["access_expires_at"]);
236
- if (access !== "" && until !== null && until - this.now() > EARLY_MS) return access;
237
- const refresh = String(row["refresh_token"] ?? "");
238
- if (refresh === "") return "";
239
- let grant: TokenGrant;
240
- try {
241
- grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }));
242
- } catch {
243
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
244
- return "";
245
- }
246
- await this.db.query(
247
- `UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`,
248
- [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh],
249
- );
250
- return grant.access_token;
264
+ return this.held(userId);
265
+ }
266
+
267
+ /** nixamp.com refused the token: forget it, so the page asks again. */
268
+ drop(userId: string): void {
269
+ this.live.delete(userId);
251
270
  }
252
271
 
253
- /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */
272
+ /** Withdraw the token on nixamp.com and forget the connection here. True when there was one. */
254
273
  async disconnect(userId: string): Promise<boolean> {
255
274
  await this.ensure();
256
- const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]);
257
- const row = rows[0];
258
- if (!row) return false;
259
- const refresh = String(row["refresh_token"] ?? "");
260
- const access = String(row["access_token"] ?? "");
261
- await this.exchange.revoke(refresh !== "" ? refresh : access);
262
- await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]);
263
- return true;
275
+ const token = this.held(userId);
276
+ this.live.delete(userId);
277
+ if (token !== "") await this.exchange.revoke(token).catch(() => undefined);
278
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 RETURNING user_id`, [userId]);
279
+ return rows.length > 0;
264
280
  }
265
281
  }
package/src/oauth.ts CHANGED
@@ -48,6 +48,7 @@ export interface Identity {
48
48
  * to an existing account and is never the new account's address.
49
49
  */
50
50
  verified?: boolean;
51
+ openprofileUrl?: string;
51
52
  }
52
53
 
53
54
  /**
@@ -73,7 +74,14 @@ export function coinpayProvider(clientId: string, clientSecret: string, base = "
73
74
  // Never verified, whatever the answer says: CoinPay sends no mail to the
74
75
  // address it reports, so anybody can register a CoinPay account under
75
76
  // someone else's address. Its `sub` is the whole identity.
76
- return { provider: "coinpay", subject, email, verified: false };
77
+ return {
78
+ provider: "coinpay",
79
+ subject,
80
+ email,
81
+ verified: false,
82
+ // Only when CoinPay names one: an absent field stays absent, not `undefined`.
83
+ ...(typeof who["openprofile_url"] === "string" ? { openprofileUrl: who["openprofile_url"] } : {}),
84
+ };
77
85
  },
78
86
  };
79
87
  }