nixamp 0.45.5 → 0.46.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.
- package/dist/break-silence.d.ts +27 -0
- package/dist/break-silence.js +57 -0
- package/dist/channels.d.ts +21 -0
- package/dist/channels.js +82 -0
- package/dist/listen-pass.d.ts +29 -0
- package/dist/listen-pass.js +93 -0
- package/dist/server.js +31 -0
- package/dist/station-page.js +2 -1
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +10 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -1
- package/dist/stations.d.ts +2 -0
- package/dist/stations.js +5 -1
- package/package.json +3 -3
- package/src/break-silence.ts +65 -0
- package/src/channels.ts +78 -0
- package/src/listen-pass.ts +104 -0
- package/src/server.ts +33 -0
- package/src/station-page.ts +2 -1
- package/src/station-routes.ts +12 -2
- package/src/station-usage.ts +1 -1
- package/src/stations.ts +5 -1
- package/web/dist/assets/{ar-CebVvHAw.js → ar-Db_tDh8O.js} +1 -1
- package/web/dist/assets/{cs-CWbKpezF.js → cs-BlaJwVKZ.js} +1 -1
- package/web/dist/assets/{da-DDeqVz72.js → da-rqJgHbtd.js} +1 -1
- package/web/dist/assets/{de-BIcO1iEz.js → de-D3jfQ5X1.js} +1 -1
- package/web/dist/assets/{es-rha6a6uY.js → es-DL3ceO0F.js} +1 -1
- package/web/dist/assets/{fi-DQVrR7HD.js → fi-C9uYTqZj.js} +1 -1
- package/web/dist/assets/{fr-7GBVEDb9.js → fr-BIMC-Rho.js} +1 -1
- package/web/dist/assets/{hi-DgWvU_Lm.js → hi-B6Wzcc1h.js} +1 -1
- package/web/dist/assets/{hu-ev2AI3vJ.js → hu-B_1agTpY.js} +1 -1
- package/web/dist/assets/{id-CEpAesOA.js → id-Cqvy9k6T.js} +1 -1
- package/web/dist/assets/{it-XGRhkpyV.js → it-D7dik-D0.js} +1 -1
- package/web/dist/assets/main-BGqOUW4j.js +22 -0
- package/web/dist/assets/{nl-C3AxtpK7.js → nl-DMbfbT8D.js} +1 -1
- package/web/dist/assets/{ru-DK2Ja4_w.js → ru-BZj1t0h7.js} +1 -1
- package/web/dist/assets/{station-Djxdr3MD.js → station-BhCxDaWb.js} +1 -1
- package/web/dist/assets/{sv-BYUU-a1y.js → sv-B8a1GD58.js} +1 -1
- package/web/dist/assets/{uk-TxpZ1viH.js → uk-BpEXCXvC.js} +1 -1
- package/web/dist/assets/{vi-Di4kvWLk.js → vi-B2ch2kpO.js} +1 -1
- package/web/dist/assets/{zh-BjOk7rqG.js → zh-B6GcwVnN.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
- package/web/dist/assets/main-B6IHgkWQ.js +0 -22
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence in place of a break, for one listener of a station: the last resort.
|
|
3
|
+
*
|
|
4
|
+
* A listener with a listen pass (listen-pass.ts) skips breaks out of a head
|
|
5
|
+
* start of music (Channel.listenWithoutBreaks). Only when that is spent is a
|
|
6
|
+
* break replaced, by MP3 silence lasting as long as the bytes it stands in
|
|
7
|
+
* for, rather than a gap that stalls the player. Audio only; a
|
|
8
|
+
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
|
+
* everybody's.
|
|
10
|
+
*
|
|
11
|
+
* The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
|
|
12
|
+
* resampled to) with empty side information: four header bytes and zeros,
|
|
13
|
+
* which every decoder plays as nothing.
|
|
14
|
+
*/
|
|
15
|
+
/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
|
|
16
|
+
export declare function silentFrame(kbps: number): Buffer;
|
|
17
|
+
export interface Writer {
|
|
18
|
+
write: (chunk: Buffer) => boolean;
|
|
19
|
+
end: () => void;
|
|
20
|
+
pending?: () => number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A writer that sends silence instead of whatever is written while `inBreak()`
|
|
24
|
+
* says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
|
|
25
|
+
* seconds.
|
|
26
|
+
*/
|
|
27
|
+
export declare function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence in place of a break, for one listener of a station: the last resort.
|
|
3
|
+
*
|
|
4
|
+
* A listener with a listen pass (listen-pass.ts) skips breaks out of a head
|
|
5
|
+
* start of music (Channel.listenWithoutBreaks). Only when that is spent is a
|
|
6
|
+
* break replaced, by MP3 silence lasting as long as the bytes it stands in
|
|
7
|
+
* for, rather than a gap that stalls the player. Audio only; a
|
|
8
|
+
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
|
+
* everybody's.
|
|
10
|
+
*
|
|
11
|
+
* The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
|
|
12
|
+
* resampled to) with empty side information: four header bytes and zeros,
|
|
13
|
+
* which every decoder plays as nothing.
|
|
14
|
+
*/
|
|
15
|
+
const SAMPLE_RATE = 48_000;
|
|
16
|
+
const FRAME_SECONDS = 1152 / SAMPLE_RATE;
|
|
17
|
+
/** MPEG-1 Layer III bitrate index by kbps. */
|
|
18
|
+
const BITRATE_INDEX = {
|
|
19
|
+
32: 1, 40: 2, 48: 3, 56: 4, 64: 5, 80: 6, 96: 7, 112: 8, 128: 9, 160: 10, 192: 11, 224: 12, 256: 13, 320: 14,
|
|
20
|
+
};
|
|
21
|
+
/** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
|
|
22
|
+
export function silentFrame(kbps) {
|
|
23
|
+
const rate = BITRATE_INDEX[kbps] ? kbps : 128;
|
|
24
|
+
const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE);
|
|
25
|
+
// Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo.
|
|
26
|
+
frame[0] = 0xff;
|
|
27
|
+
frame[1] = 0xfb;
|
|
28
|
+
frame[2] = (BITRATE_INDEX[rate] << 4) | (0b01 << 2);
|
|
29
|
+
frame[3] = 0x00;
|
|
30
|
+
return frame;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A writer that sends silence instead of whatever is written while `inBreak()`
|
|
34
|
+
* says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
|
|
35
|
+
* seconds.
|
|
36
|
+
*/
|
|
37
|
+
export function quietDuringBreaks(out, kbps, inBreak) {
|
|
38
|
+
const frame = silentFrame(kbps);
|
|
39
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
40
|
+
let owed = 0;
|
|
41
|
+
return {
|
|
42
|
+
write: (chunk) => {
|
|
43
|
+
if (!inBreak()) {
|
|
44
|
+
owed = 0;
|
|
45
|
+
return out.write(chunk);
|
|
46
|
+
}
|
|
47
|
+
owed += chunk.length / bytesPerSecond;
|
|
48
|
+
const frames = Math.floor(owed / FRAME_SECONDS);
|
|
49
|
+
if (frames === 0)
|
|
50
|
+
return true;
|
|
51
|
+
owed -= frames * FRAME_SECONDS;
|
|
52
|
+
return out.write(Buffer.concat(Array.from({ length: frames }, () => frame)));
|
|
53
|
+
},
|
|
54
|
+
end: () => out.end(),
|
|
55
|
+
...(out.pending ? { pending: out.pending } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/channels.d.ts
CHANGED
|
@@ -168,6 +168,12 @@ export declare const BACKLOG_AUDIO: number;
|
|
|
168
168
|
* until the channel ended, however long that was.
|
|
169
169
|
*/
|
|
170
170
|
export declare const LISTENER_QUEUE: number;
|
|
171
|
+
/**
|
|
172
|
+
* How much break-free radio a listener who skips breaks is handed on arrival:
|
|
173
|
+
* two minutes at 192k. Their player holds it, and each break they skip spends
|
|
174
|
+
* the length of the spot from it, so they never hear a gap.
|
|
175
|
+
*/
|
|
176
|
+
export declare const MUSIC_BACKLOG: number;
|
|
171
177
|
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
172
178
|
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
173
179
|
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
@@ -270,6 +276,11 @@ export declare class Channel {
|
|
|
270
276
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
271
277
|
*/
|
|
272
278
|
private recent;
|
|
279
|
+
/** Whether what the encoder is writing now is a break (a radio list's spot). */
|
|
280
|
+
private breakOn;
|
|
281
|
+
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
282
|
+
private music;
|
|
283
|
+
private musicBytes;
|
|
273
284
|
private fragmentClock;
|
|
274
285
|
private fragmentTimes;
|
|
275
286
|
private recentBytes;
|
|
@@ -420,6 +431,14 @@ export declare class Channel {
|
|
|
420
431
|
*/
|
|
421
432
|
private feedThrough;
|
|
422
433
|
listen(listener: Listener): () => void;
|
|
434
|
+
/**
|
|
435
|
+
* A radio list's listener who hears no breaks: handed the last couple of
|
|
436
|
+
* minutes of music at once, which their player holds, then everything but
|
|
437
|
+
* the breaks. Each skipped break spends its length of that head start, so
|
|
438
|
+
* there is no gap; only once it is all spent does a break become silence of
|
|
439
|
+
* the same length (`silence`), never the spot. Joining again refills it.
|
|
440
|
+
*/
|
|
441
|
+
listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void;
|
|
423
442
|
/** Stay up with nobody watching: no longer on demand. */
|
|
424
443
|
keep(): void;
|
|
425
444
|
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
@@ -485,6 +504,8 @@ export declare class Channels {
|
|
|
485
504
|
contentType(id: string): string;
|
|
486
505
|
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
487
506
|
listen(id: string, listener: Listener): (() => void) | null;
|
|
507
|
+
/** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
|
|
508
|
+
listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null;
|
|
488
509
|
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
489
510
|
writeTo(id: string, chunk: Buffer): boolean;
|
|
490
511
|
/**
|
package/dist/channels.js
CHANGED
|
@@ -66,6 +66,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
|
|
|
66
66
|
* until the channel ended, however long that was.
|
|
67
67
|
*/
|
|
68
68
|
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* How much break-free radio a listener who skips breaks is handed on arrival:
|
|
71
|
+
* two minutes at 192k. Their player holds it, and each break they skip spends
|
|
72
|
+
* the length of the spot from it, so they never hear a gap.
|
|
73
|
+
*/
|
|
74
|
+
export const MUSIC_BACKLOG = 3 * 1024 * 1024;
|
|
69
75
|
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
70
76
|
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
71
77
|
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
@@ -190,6 +196,11 @@ export class Channel {
|
|
|
190
196
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
191
197
|
*/
|
|
192
198
|
recent = [];
|
|
199
|
+
/** Whether what the encoder is writing now is a break (a radio list's spot). */
|
|
200
|
+
breakOn = false;
|
|
201
|
+
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
202
|
+
music = [];
|
|
203
|
+
musicBytes = 0;
|
|
193
204
|
fragmentClock = new FragmentClock();
|
|
194
205
|
fragmentTimes = new WeakMap();
|
|
195
206
|
recentBytes = 0;
|
|
@@ -412,6 +423,13 @@ export class Channel {
|
|
|
412
423
|
}
|
|
413
424
|
});
|
|
414
425
|
child.stdio[3]?.on("error", () => undefined);
|
|
426
|
+
// A radio list's output is constant-bitrate MP3, so how many bytes it
|
|
427
|
+
// has written is exactly how far it has played: whether this chunk is
|
|
428
|
+
// a break, to the frame, rather than to progress's once a second.
|
|
429
|
+
const kbps = playlistInput && this.info.kind !== "video"
|
|
430
|
+
? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
|
|
431
|
+
: 0;
|
|
432
|
+
let written = 0;
|
|
415
433
|
child.stdout?.on("data", (chunk) => {
|
|
416
434
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
417
435
|
if (this.child !== child)
|
|
@@ -421,6 +439,12 @@ export class Channel {
|
|
|
421
439
|
sent = true;
|
|
422
440
|
this.info.bytes += chunk.byteLength;
|
|
423
441
|
this.rearm(child);
|
|
442
|
+
if (kbps > 0 && playlistInput) {
|
|
443
|
+
this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
|
|
444
|
+
written += chunk.byteLength;
|
|
445
|
+
}
|
|
446
|
+
else
|
|
447
|
+
this.breakOn = this.info.playlistAt === -1;
|
|
424
448
|
this.emit(chunk);
|
|
425
449
|
});
|
|
426
450
|
child.stdout?.on("error", () => undefined);
|
|
@@ -669,6 +693,13 @@ export class Channel {
|
|
|
669
693
|
this.measure(chunk.byteLength);
|
|
670
694
|
if (!this.fragments) {
|
|
671
695
|
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
696
|
+
if (this.info.playlist && !this.breakOn) {
|
|
697
|
+
this.music.push(chunk);
|
|
698
|
+
this.musicBytes += chunk.byteLength;
|
|
699
|
+
while (this.music.length > 1 && this.musicBytes - this.music[0].byteLength > MUSIC_BACKLOG) {
|
|
700
|
+
this.musicBytes -= this.music.shift().byteLength;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
672
703
|
this.send(chunk);
|
|
673
704
|
return;
|
|
674
705
|
}
|
|
@@ -934,6 +965,52 @@ export class Channel {
|
|
|
934
965
|
this.idleOut();
|
|
935
966
|
};
|
|
936
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* A radio list's listener who hears no breaks: handed the last couple of
|
|
970
|
+
* minutes of music at once, which their player holds, then everything but
|
|
971
|
+
* the breaks. Each skipped break spends its length of that head start, so
|
|
972
|
+
* there is no gap; only once it is all spent does a break become silence of
|
|
973
|
+
* the same length (`silence`), never the spot. Joining again refills it.
|
|
974
|
+
*/
|
|
975
|
+
listenWithoutBreaks(listener, kbps, silence) {
|
|
976
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
977
|
+
let ahead = 0;
|
|
978
|
+
for (const piece of this.music) {
|
|
979
|
+
try {
|
|
980
|
+
listener.write(piece);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
ahead += piece.byteLength / bytesPerSecond;
|
|
986
|
+
}
|
|
987
|
+
const quiet = silence(listener);
|
|
988
|
+
const skipping = {
|
|
989
|
+
write: (chunk) => {
|
|
990
|
+
if (!this.breakOn)
|
|
991
|
+
return listener.write(chunk);
|
|
992
|
+
const seconds = chunk.byteLength / bytesPerSecond;
|
|
993
|
+
if (ahead >= seconds) {
|
|
994
|
+
ahead -= seconds;
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
return quiet.write(chunk);
|
|
998
|
+
},
|
|
999
|
+
end: () => listener.end(),
|
|
1000
|
+
...(listener.pending ? { pending: () => listener.pending() } : {}),
|
|
1001
|
+
};
|
|
1002
|
+
this.listeners.add(skipping);
|
|
1003
|
+
this.info.listeners = this.listeners.size;
|
|
1004
|
+
if (this.idle)
|
|
1005
|
+
clearTimeout(this.idle);
|
|
1006
|
+
this.idle = null;
|
|
1007
|
+
return () => {
|
|
1008
|
+
this.listeners.delete(skipping);
|
|
1009
|
+
this.info.listeners = this.listeners.size;
|
|
1010
|
+
if (this.ephemeral && this.listeners.size === 0)
|
|
1011
|
+
this.idleOut();
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
937
1014
|
/** Stay up with nobody watching: no longer on demand. */
|
|
938
1015
|
keep() {
|
|
939
1016
|
this.ephemeral = false;
|
|
@@ -1140,6 +1217,11 @@ export class Channels {
|
|
|
1140
1217
|
const channel = this.open.get(id);
|
|
1141
1218
|
return channel ? channel.listen(listener) : null;
|
|
1142
1219
|
}
|
|
1220
|
+
/** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
|
|
1221
|
+
listenWithoutBreaks(id, listener, kbps, silence) {
|
|
1222
|
+
const channel = this.open.get(id);
|
|
1223
|
+
return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
|
|
1224
|
+
}
|
|
1143
1225
|
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
1144
1226
|
writeTo(id, chunk) {
|
|
1145
1227
|
return this.open.get(id)?.write(chunk) ?? false;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** How long a pass is good for. The page asks again on every load. */
|
|
2
|
+
export declare const PASS_TTL_SECONDS: number;
|
|
3
|
+
/** Where a pass rides on a stream link (an audio element sends no headers). */
|
|
4
|
+
export declare const PASS_PARAM = "pass";
|
|
5
|
+
/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
|
|
6
|
+
export declare function listenPassSecret(env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
export declare function mintListenPass(secret: string, nowMs?: number, ttlSeconds?: number): {
|
|
8
|
+
pass: string;
|
|
9
|
+
expires: number;
|
|
10
|
+
};
|
|
11
|
+
/** The expiry of a good pass in seconds, or 0. */
|
|
12
|
+
export declare function checkListenPass(secret: string, pass: string, nowMs?: number): number;
|
|
13
|
+
export interface VerifierOptions {
|
|
14
|
+
/** nixamp.com, which signed the pass. */
|
|
15
|
+
site: string;
|
|
16
|
+
/** Set on nixamp.com itself, which checks its own passes without asking. */
|
|
17
|
+
secret?: string;
|
|
18
|
+
fetcher?: typeof fetch;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
|
|
22
|
+
export declare class ListenPassVerifier {
|
|
23
|
+
private readonly options;
|
|
24
|
+
/** pass -> whether it is good, and until when that answer stands. */
|
|
25
|
+
private readonly known;
|
|
26
|
+
constructor(options: VerifierOptions);
|
|
27
|
+
private now;
|
|
28
|
+
good(pass: string): Promise<boolean>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listen pass: what a signed-in nixamp.com listener carries to a station
|
|
3
|
+
* on another server, so its breaks go quiet for them.
|
|
4
|
+
*
|
|
5
|
+
* Breaks are in the stream (playlist-input.ts), one stream for every
|
|
6
|
+
* listener, so a station cannot leave a spot out for one person: it can only
|
|
7
|
+
* send that person silence while everybody else hears it. To do even that it
|
|
8
|
+
* has to know who is signed in, and a station on server1 holds no accounts.
|
|
9
|
+
* nixamp.com does, so nixamp.com signs a short pass for a signed-in account
|
|
10
|
+
* (GET /api/v1/me/pass, credentialed from any *.nixamp.com page) and the
|
|
11
|
+
* station asks nixamp.com whether a pass is good (GET
|
|
12
|
+
* /api/v1/listen-pass/check), remembering the answer.
|
|
13
|
+
*
|
|
14
|
+
* A pass is `v1.<expiry seconds>.<signature>` and nothing else: no account
|
|
15
|
+
* id, because a station has no use for one and a link gets pasted around.
|
|
16
|
+
* Anybody holding a fresh pass hears no breaks until it expires, which is the
|
|
17
|
+
* same thing a signed-in listener sharing their screen would give away.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
20
|
+
/** How long a pass is good for. The page asks again on every load. */
|
|
21
|
+
export const PASS_TTL_SECONDS = 24 * 60 * 60;
|
|
22
|
+
/** Where a pass rides on a stream link (an audio element sends no headers). */
|
|
23
|
+
export const PASS_PARAM = "pass";
|
|
24
|
+
/** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
|
|
25
|
+
export function listenPassSecret(env = process.env) {
|
|
26
|
+
if (env["NIXAMP_LISTEN_PASS_SECRET"])
|
|
27
|
+
return env["NIXAMP_LISTEN_PASS_SECRET"];
|
|
28
|
+
// Derived, so nixamp.com needs no new variable: a pass is not a session.
|
|
29
|
+
const jwt = env["NIXAMP_JWT_SECRET"] ?? "";
|
|
30
|
+
return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : "";
|
|
31
|
+
}
|
|
32
|
+
function sign(secret, body) {
|
|
33
|
+
return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32);
|
|
34
|
+
}
|
|
35
|
+
export function mintListenPass(secret, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS) {
|
|
36
|
+
const expires = Math.floor(nowMs / 1000) + ttlSeconds;
|
|
37
|
+
const body = `v1.${expires}`;
|
|
38
|
+
return { pass: `${body}.${sign(secret, body)}`, expires };
|
|
39
|
+
}
|
|
40
|
+
/** The expiry of a good pass in seconds, or 0. */
|
|
41
|
+
export function checkListenPass(secret, pass, nowMs = Date.now()) {
|
|
42
|
+
const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass);
|
|
43
|
+
if (!secret || !match)
|
|
44
|
+
return 0;
|
|
45
|
+
const expires = Number(match[1]);
|
|
46
|
+
if (expires * 1000 <= nowMs)
|
|
47
|
+
return 0;
|
|
48
|
+
const want = Buffer.from(sign(secret, `v1.${expires}`));
|
|
49
|
+
const got = Buffer.from(match[2]);
|
|
50
|
+
return want.length === got.length && timingSafeEqual(want, got) ? expires : 0;
|
|
51
|
+
}
|
|
52
|
+
/** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */
|
|
53
|
+
const REFUSED_MS = 5 * 60 * 1000;
|
|
54
|
+
const MAX_REMEMBERED = 5_000;
|
|
55
|
+
/** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
|
|
56
|
+
export class ListenPassVerifier {
|
|
57
|
+
options;
|
|
58
|
+
/** pass -> whether it is good, and until when that answer stands. */
|
|
59
|
+
known = new Map();
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.options = options;
|
|
62
|
+
}
|
|
63
|
+
now() {
|
|
64
|
+
return this.options.now?.() ?? Date.now();
|
|
65
|
+
}
|
|
66
|
+
async good(pass) {
|
|
67
|
+
if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass))
|
|
68
|
+
return false;
|
|
69
|
+
if (this.options.secret)
|
|
70
|
+
return checkListenPass(this.options.secret, pass, this.now()) > 0;
|
|
71
|
+
const seen = this.known.get(pass);
|
|
72
|
+
if (seen && seen.until > this.now())
|
|
73
|
+
return seen.good;
|
|
74
|
+
let expires = 0;
|
|
75
|
+
try {
|
|
76
|
+
const answer = await (this.options.fetcher ?? fetch)(`${this.options.site.replace(/\/+$/, "")}/api/v1/listen-pass/check?${PASS_PARAM}=${encodeURIComponent(pass)}`, { signal: AbortSignal.timeout(2_000) });
|
|
77
|
+
// nixamp.com down is not a refusal: nothing is remembered, and this
|
|
78
|
+
// listener hears the break this time.
|
|
79
|
+
if (answer.status >= 500)
|
|
80
|
+
return false;
|
|
81
|
+
const body = (await answer.json().catch(() => ({})));
|
|
82
|
+
expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (this.known.size >= MAX_REMEMBERED)
|
|
88
|
+
this.known.clear();
|
|
89
|
+
const good = expires * 1000 > this.now();
|
|
90
|
+
this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS });
|
|
91
|
+
return good;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -101,6 +101,7 @@ import { EventWriter } from "./event-writer.js";
|
|
|
101
101
|
import { handleLiveApi } from "./live-api.js";
|
|
102
102
|
import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
|
|
103
103
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
|
|
104
|
+
import { ListenPassVerifier, PASS_PARAM, checkListenPass, listenPassSecret, mintListenPass } from "./listen-pass.js";
|
|
104
105
|
import { Layouts } from "./layouts.js";
|
|
105
106
|
import { Rooms } from "./rooms.js";
|
|
106
107
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
|
|
@@ -1246,6 +1247,9 @@ export function isSignInPath(path) {
|
|
|
1246
1247
|
path === "/api/v1/servers" ||
|
|
1247
1248
|
path.startsWith("/api/v1/servers/") ||
|
|
1248
1249
|
path === "/api/v1/me/handle" ||
|
|
1250
|
+
// A listen pass is the session's to ask for and anybody's to check.
|
|
1251
|
+
path === "/api/v1/me/pass" ||
|
|
1252
|
+
path === "/api/v1/listen-pass/check" ||
|
|
1249
1253
|
path === "/api/v1/me/profile" ||
|
|
1250
1254
|
path.startsWith("/api/v1/me/profile/") ||
|
|
1251
1255
|
// A stored photo is public: it is on every class the host runs.
|
|
@@ -1478,6 +1482,12 @@ export function createHandler(engine, options) {
|
|
|
1478
1482
|
// away, which are the same thing as far as "is it still going" goes.
|
|
1479
1483
|
response.once("close", () => tracker.close(id));
|
|
1480
1484
|
};
|
|
1485
|
+
// Signs listen passes where accounts live (nixamp.com); "" everywhere else.
|
|
1486
|
+
const listenPassKey = listenPassSecret();
|
|
1487
|
+
const listenPasses = new ListenPassVerifier({
|
|
1488
|
+
site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
|
|
1489
|
+
...(listenPassKey ? { secret: listenPassKey } : {}),
|
|
1490
|
+
});
|
|
1481
1491
|
return async function handle(request, response) {
|
|
1482
1492
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1483
1493
|
const path = url.pathname;
|
|
@@ -2521,6 +2531,7 @@ export function createHandler(engine, options) {
|
|
|
2521
2531
|
...(options.stations.cover ? { cover: options.stations.cover } : {}),
|
|
2522
2532
|
...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
|
|
2523
2533
|
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
2534
|
+
passes: listenPasses,
|
|
2524
2535
|
...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
|
|
2525
2536
|
...(options.stations.rooms ? {
|
|
2526
2537
|
rooms: () => roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
|
|
@@ -3758,6 +3769,26 @@ export function createHandler(engine, options) {
|
|
|
3758
3769
|
});
|
|
3759
3770
|
return;
|
|
3760
3771
|
}
|
|
3772
|
+
// A listen pass (listen-pass.ts): a station page on any *.nixamp.com server
|
|
3773
|
+
// asks for one with this site's cookie, which is same-site there, so a
|
|
3774
|
+
// signed-in listener hears no breaks. Credentialed, so never "*".
|
|
3775
|
+
if (path === "/api/v1/me/pass" && request.method === "GET" && options.accounts && listenPassKey) {
|
|
3776
|
+
const origin = String(request.headers.origin ?? "");
|
|
3777
|
+
const cors = /^https:\/\/([a-z0-9-]+\.)*nixamp\.com(:\d+)?$/.test(origin)
|
|
3778
|
+
? { "access-control-allow-origin": origin, "access-control-allow-credentials": "true", vary: "origin" }
|
|
3779
|
+
: {};
|
|
3780
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
3781
|
+
const body = who === null ? { error: "not signed in" } : mintListenPass(listenPassKey);
|
|
3782
|
+
response.writeHead(who === null ? 401 : 200, { ...cors, "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
3783
|
+
response.end(JSON.stringify(body));
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
// Asked by a station on another server about a pass it was handed.
|
|
3787
|
+
if (path === "/api/v1/listen-pass/check" && request.method === "GET" && listenPassKey) {
|
|
3788
|
+
const expires = checkListenPass(listenPassKey, url.searchParams.get(PASS_PARAM) ?? "");
|
|
3789
|
+
json(response, 200, expires > 0 ? { ok: true, expires } : { ok: false });
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3761
3792
|
if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
|
|
3762
3793
|
const handles = options.handles;
|
|
3763
3794
|
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
package/dist/station-page.js
CHANGED
|
@@ -188,7 +188,8 @@ export const STATION_APP_CSP = [
|
|
|
188
188
|
"media-src 'self' blob:",
|
|
189
189
|
"worker-src 'self' blob:",
|
|
190
190
|
"img-src 'self' data: blob:",
|
|
191
|
-
|
|
191
|
+
// nixamp.com for a listen pass, so a signed-in listener's breaks go quiet.
|
|
192
|
+
"connect-src 'self' https://nixamp.com",
|
|
192
193
|
"base-uri 'none'",
|
|
193
194
|
"form-action 'none'",
|
|
194
195
|
"frame-ancestors 'none'",
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export interface StationContext {
|
|
|
76
76
|
status: () => unknown;
|
|
77
77
|
publish: (station: Station) => Promise<PublishReport>;
|
|
78
78
|
};
|
|
79
|
+
/** Whether a listen pass is good: its holder hears silence in place of a radio station's breaks. */
|
|
80
|
+
passes?: {
|
|
81
|
+
good: (pass: string) => Promise<boolean>;
|
|
82
|
+
};
|
|
79
83
|
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
80
84
|
auto?: {
|
|
81
85
|
sync: (input: Record<string, unknown>) => Promise<unknown>;
|
package/dist/station-routes.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { alertsFor } from "./station-metering.js";
|
|
2
2
|
import { aiFromTags } from "./track-facts.js";
|
|
3
|
+
import { quietDuringBreaks } from "./break-silence.js";
|
|
4
|
+
import { PASS_PARAM } from "./listen-pass.js";
|
|
3
5
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
4
6
|
/** The three things a station's link opens. */
|
|
5
7
|
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
@@ -244,6 +246,10 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
244
246
|
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
245
247
|
return true;
|
|
246
248
|
}
|
|
249
|
+
// Signed in at nixamp.com: no breaks, starting a little behind live so
|
|
250
|
+
// they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
|
|
251
|
+
const pass = url.searchParams.get(PASS_PARAM) ?? "";
|
|
252
|
+
const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
|
|
247
253
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
248
254
|
if (method === "HEAD") {
|
|
249
255
|
response.end();
|
|
@@ -251,7 +257,7 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
251
257
|
}
|
|
252
258
|
// Every byte that goes out through the link is counted against the station.
|
|
253
259
|
const usage = ctx.usage;
|
|
254
|
-
const
|
|
260
|
+
const metered = usage
|
|
255
261
|
? {
|
|
256
262
|
write: (chunk) => {
|
|
257
263
|
usage.delivered(station.id, chunk.length);
|
|
@@ -261,7 +267,9 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
261
267
|
pending: () => response.writableLength,
|
|
262
268
|
}
|
|
263
269
|
: response;
|
|
264
|
-
const detach =
|
|
270
|
+
const detach = spared
|
|
271
|
+
? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
|
|
272
|
+
: ctx.channels.listen(channelId, metered);
|
|
265
273
|
if (detach === null) {
|
|
266
274
|
response.end();
|
|
267
275
|
return true;
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
|
|
2
|
-
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
|
2
|
+
export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
|
package/dist/station-usage.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const USAGE = `nixamp station — make a collection into a station
|
|
3
3
|
|
|
4
4
|
nixamp station list
|
|
5
|
-
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]
|
|
5
|
+
nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]
|
|
6
6
|
nixamp station show ID
|
|
7
7
|
nixamp station status ID
|
|
8
8
|
nixamp station start ID
|
package/dist/stations.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface Station {
|
|
|
45
45
|
/** Times an item may fail in a row before rotation leaves it out. */
|
|
46
46
|
export declare const EXCLUDE_AFTER = 3;
|
|
47
47
|
export declare const DEFAULT_BITRATE = 128;
|
|
48
|
+
/** Seconds between breaks on a new station that did not say. */
|
|
49
|
+
export declare const DEFAULT_BREAKS_EVERY = 600;
|
|
48
50
|
export declare const BITRATES: number[];
|
|
49
51
|
/** A station's channel id. */
|
|
50
52
|
export declare function channelOf(id: string): string;
|
package/dist/stations.js
CHANGED
|
@@ -26,6 +26,8 @@ export const RIGHTS_BASES = ["owned", "permission", "licensed", "public-domain"]
|
|
|
26
26
|
/** Times an item may fail in a row before rotation leaves it out. */
|
|
27
27
|
export const EXCLUDE_AFTER = 3;
|
|
28
28
|
export const DEFAULT_BITRATE = 128;
|
|
29
|
+
/** Seconds between breaks on a new station that did not say. */
|
|
30
|
+
export const DEFAULT_BREAKS_EVERY = 600;
|
|
29
31
|
export const BITRATES = [64, 96, 128, 160, 192, 256, 320];
|
|
30
32
|
const MAX_ITEMS = 20_000;
|
|
31
33
|
const MAX_NAME = 120;
|
|
@@ -179,7 +181,9 @@ export class StationStore {
|
|
|
179
181
|
listenToken: newToken(),
|
|
180
182
|
bitrate: this.bitrateFrom(input.bitrate),
|
|
181
183
|
video: input.video === true,
|
|
182
|
-
|
|
184
|
+
// On unless asked for none: a station is free to listen to, and the
|
|
185
|
+
// spot is what pays for it. Signed-in listeners hear silence instead.
|
|
186
|
+
breaksEvery: input.breaksEvery === undefined ? DEFAULT_BREAKS_EVERY : this.breaksFrom(input.breaksEvery),
|
|
183
187
|
auto: "",
|
|
184
188
|
rights: null,
|
|
185
189
|
owner,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.1",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@profullstack/notifications": "^0.1.0",
|
|
52
52
|
"@profullstack/synconfig": "^0.1.2",
|
|
53
53
|
"@profullstack/throttle": "0.2.2",
|
|
54
|
-
"@profullstack/x402-gateway": "^0.
|
|
54
|
+
"@profullstack/x402-gateway": "^0.10.1",
|
|
55
55
|
"acme-client": "5.4.0",
|
|
56
56
|
"pg": "^8.23.0"
|
|
57
57
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
"overrides": {
|
|
85
85
|
"https-proxy-agent": "7.0.6",
|
|
86
86
|
"tslib": "2.8.1",
|
|
87
|
-
"@profullstack/x402-gateway": "^0.
|
|
87
|
+
"@profullstack/x402-gateway": "^0.10.1"
|
|
88
88
|
}
|
|
89
89
|
}
|