nixamp 0.78.6 → 0.80.2
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.
- package/README.md +6 -5
- package/backtoschool/dist/assets/{ar-BBh9pRWn.js → ar-BYDVUbTZ.js} +1 -1
- package/backtoschool/dist/assets/{cs-DYDz-hZb.js → cs-DsCx7BAy.js} +1 -1
- package/backtoschool/dist/assets/{da-DL0tbrTr.js → da-5tLM7Wal.js} +1 -1
- package/backtoschool/dist/assets/{de-POU_MZN1.js → de-2Q8k6IKm.js} +1 -1
- package/backtoschool/dist/assets/{es-VLEhXfMp.js → es-CVwFkQ0E.js} +1 -1
- package/backtoschool/dist/assets/{fi-B__GWwbQ.js → fi-D7KuvUvL.js} +1 -1
- package/backtoschool/dist/assets/{fr-DDtDAMct.js → fr-CkWg-nDv.js} +1 -1
- package/backtoschool/dist/assets/{hi-C6MybeLD.js → hi-DayNvhO4.js} +1 -1
- package/backtoschool/dist/assets/{hls-OR5FY6YP-CL3tmveA.js → hls-OR5FY6YP-D2hwLr-P.js} +1 -1
- package/backtoschool/dist/assets/{hu-CgiQxTUY.js → hu-BqS46UyM.js} +1 -1
- package/backtoschool/dist/assets/{id-pzntv5iA.js → id-DTiKw_BS.js} +1 -1
- package/backtoschool/dist/assets/{index-DeCDKrPz.js → index-EM-0SwBp.js} +2 -2
- package/backtoschool/dist/assets/{it-dKw8vCrE.js → it-B60apeGa.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-B6-Xba87.js → mpegts-C4v5WI5W.js} +1 -1
- package/backtoschool/dist/assets/{mpegts-Y2FARLFE-MgjbiVoD.js → mpegts-Y2FARLFE-D4OTyGyp.js} +1 -1
- package/backtoschool/dist/assets/{nl-DZ0o7967.js → nl-DctlIuWS.js} +1 -1
- package/backtoschool/dist/assets/{ru-s6C6TpM6.js → ru-CO8g0gfi.js} +1 -1
- package/backtoschool/dist/assets/{sv-BHj0BZlD.js → sv-CoFMz-Qg.js} +1 -1
- package/backtoschool/dist/assets/{uk-BH7Y4zb1.js → uk-C3_GprSP.js} +1 -1
- package/backtoschool/dist/assets/{vi-nf4HfTQk.js → vi-B0RDG4iN.js} +1 -1
- package/backtoschool/dist/assets/{zh-CT2P8jxm.js → zh--yO6izcR.js} +1 -1
- package/backtoschool/dist/index.html +1 -1
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +1 -1
- package/dist/ai-label.d.ts +38 -0
- package/dist/ai-label.js +238 -5
- package/dist/ai-text.d.ts +42 -1
- package/dist/ai-text.js +69 -7
- package/dist/event-writer.d.ts +10 -1
- package/dist/event-writer.js +27 -8
- package/dist/label-routes.js +9 -0
- package/dist/library-mcp.d.ts +6 -6
- package/dist/music-hub.d.ts +11 -0
- package/dist/music-hub.js +33 -2
- package/dist/nixamp-link-api.d.ts +5 -1
- package/dist/nixamp-link-api.js +12 -2
- package/dist/nixamp-link-types.d.ts +2 -0
- package/dist/nixamp-link.d.ts +22 -9
- package/dist/nixamp-link.js +75 -58
- package/dist/oauth.d.ts +1 -0
- package/dist/oauth.js +8 -1
- package/dist/server.js +18 -9
- package/dist/station-cli.js +24 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +4 -1
- package/dist/universal-footer.d.ts +2 -0
- package/dist/universal-footer.js +39 -0
- package/package.json +7 -5
- package/src/ai-keys.ts +1 -1
- package/src/ai-label.ts +241 -5
- package/src/ai-text.ts +84 -8
- package/src/event-writer.ts +36 -11
- package/src/label-routes.ts +9 -0
- package/src/music-hub.ts +38 -2
- package/src/nixamp-link-api.ts +15 -3
- package/src/nixamp-link-types.ts +2 -0
- package/src/nixamp-link.ts +77 -61
- package/src/oauth.ts +9 -1
- package/src/server.ts +18 -9
- package/src/station-cli.ts +23 -0
- package/src/station-usage.ts +4 -1
- package/src/universal-footer.ts +41 -0
- package/web/dist/assets/{ar-7tVGBWgq.js → ar-jQ_1OaPw.js} +1 -1
- package/web/dist/assets/{chunk-55GBNX64-BDcCEJUP.js → chunk-NW3WDGEQ-CnyjTRUh.js} +1 -1
- package/web/dist/assets/{cs-hTmkOWFS.js → cs-_goMpUzl.js} +1 -1
- package/web/dist/assets/{da-218F21ho.js → da-BgRIZmHA.js} +1 -1
- package/web/dist/assets/{de-DesLnw7U.js → de-BzryMgxO.js} +1 -1
- package/web/dist/assets/{es-Bc9fW4qY.js → es-Bspt-kuE.js} +1 -1
- package/web/dist/assets/{fi-Df_rWRQA.js → fi-74bqR7jK.js} +1 -1
- package/web/dist/assets/{fr-D8xzxUiQ.js → fr-C0yUFanu.js} +1 -1
- package/web/dist/assets/{hi-Ddh5AA1z.js → hi-Bow4uBzj.js} +1 -1
- package/web/dist/assets/{hls-OR5FY6YP-_WOdANsC.js → hls-OR5FY6YP-BxZwqFB9.js} +1 -1
- package/web/dist/assets/{hu-ChrJNlJD.js → hu-C_2Dn0h7.js} +1 -1
- package/web/dist/assets/{id-dNB_py4J.js → id-C8L6KxB3.js} +1 -1
- package/web/dist/assets/{it-DyHyXE3N.js → it-DMNQHxWL.js} +1 -1
- package/web/dist/assets/main-C5xMXFFr.js +33 -0
- package/web/dist/assets/{mpegts-CL9RC5Ls.js → mpegts-DPRtdBXL.js} +1 -1
- package/web/dist/assets/{mpegts-Y2FARLFE-C91SD8Pl.js → mpegts-Y2FARLFE-BY7parIQ.js} +1 -1
- package/web/dist/assets/{nl-DlwRNqNC.js → nl-8e4KRSbc.js} +1 -1
- package/web/dist/assets/{radio-BnQ78a-O.js → radio-CeRhNjpU.js} +1 -1
- package/web/dist/assets/{ru--N-8v6QS.js → ru-VCpBV7hb.js} +1 -1
- package/web/dist/assets/{station-C_6L4CDU.js → station-D5tW9jq4.js} +1 -1
- package/web/dist/assets/station-D6KfgFXf.css +1 -0
- package/web/dist/assets/{sv-DbR0DO05.js → sv-9BcESvUT.js} +1 -1
- package/web/dist/assets/{uk-Bi3fEoz1.js → uk-D0xNvfSC.js} +1 -1
- package/web/dist/assets/{vi-DqrcyLEz.js → vi-CTmBIxVv.js} +1 -1
- package/web/dist/assets/{vod-CbSC5NFA.js → vod-Dt73bt8P.js} +1 -1
- package/web/dist/assets/{watch-D-ZRxfJ_.js → watch-GxgHBQcI.js} +1 -1
- package/web/dist/assets/{zh-_4uBqFzc.js → zh-BQ28dg08.js} +1 -1
- package/web/dist/index.html +58 -12
- package/web/dist/music.html +1 -1
- package/web/dist/radio.html +5 -5
- package/web/dist/station.html +4 -4
- package/web/dist/sw.js +30 -30
- package/web/dist/vod.html +4 -4
- package/web/dist/watch.html +4 -4
- package/dist/pit-footer.d.ts +0 -3
- package/dist/pit-footer.js +0 -40
- package/src/pit-footer.ts +0 -40
- package/web/dist/assets/main-D9XA9svM.js +0 -26
- package/web/dist/assets/station-CxJvFwzV.css +0 -1
- /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
|
|
7
|
-
* so and does without (a rule of its own, or a person). The
|
|
8
|
-
* and handed back as an object; a model that rambles round
|
|
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`, {
|
package/src/event-writer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
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
|
|
149
|
+
return draftOf(JSON.parse(text));
|
|
125
150
|
}
|
|
126
151
|
}
|
package/src/label-routes.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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;
|
package/src/nixamp-link-api.ts
CHANGED
|
@@ -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 (
|
|
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 === "") {
|
|
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;
|
package/src/nixamp-link-types.ts
CHANGED
|
@@ -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;
|
package/src/nixamp-link.ts
CHANGED
|
@@ -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
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
44
|
-
|
|
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(() =>
|
|
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
|
|
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
|
|
197
|
-
VALUES ($1, $2, $3, $4
|
|
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,
|
|
200
|
-
|
|
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
|
-
|
|
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,
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
const
|
|
260
|
-
|
|
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 {
|
|
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
|
}
|