nixamp 0.16.0 → 0.17.0
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 +139 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +107 -3
- package/dist/channels.js +151 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +10 -0
- package/dist/layouts.js +71 -1
- package/dist/live-api.d.ts +16 -2
- package/dist/live-api.js +152 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +243 -11
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/server.d.ts +37 -0
- package/dist/server.js +277 -45
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +1 -1
- package/src/audio.ts +119 -6
- package/src/channels.ts +177 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +78 -1
- package/src/live-api.ts +172 -17
- package/src/live-events.ts +269 -11
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/server.ts +296 -44
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-CTxPM5KS.js +0 -1
package/src/sources.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* list; what needs care is telling the four apart, and telling an .m3u that
|
|
7
7
|
* lists tracks from an HLS playlist that *is* one track.
|
|
8
8
|
*/
|
|
9
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
9
10
|
|
|
10
11
|
/** http and https only. ffmpeg speaks more, but these are what a link is. */
|
|
11
12
|
export function isRemote(source: string): boolean {
|
|
@@ -155,3 +156,102 @@ export function playsInBrowser(source: string): boolean {
|
|
|
155
156
|
const dot = path.lastIndexOf(".");
|
|
156
157
|
return dot > 0 && WEB_READY.has(path.slice(dot));
|
|
157
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Transport streams, which is what a raw `.ts` file off a capture card, a
|
|
162
|
+
* satellite receiver or an IPTV recorder is.
|
|
163
|
+
*
|
|
164
|
+
* `.m2ts`, `.mts` and the rest name nothing else, so the extension is answer
|
|
165
|
+
* enough. `.ts` is the awkward one: it is also every TypeScript file in every
|
|
166
|
+
* repository on the machine, and this program is written in them. So a `.ts`
|
|
167
|
+
* is never taken on its name -- it is opened and asked, which costs one read
|
|
168
|
+
* of two kilobytes and is the only honest way to tell 4K television from a
|
|
169
|
+
* module.
|
|
170
|
+
*/
|
|
171
|
+
const TRANSPORT_NAMES = new Set([".m2ts", ".mts", ".m2t", ".trp", ".tp", ".mpegts"]);
|
|
172
|
+
|
|
173
|
+
/** A `.ts`, which may be a transport stream and may be a TypeScript file. */
|
|
174
|
+
export function isAmbiguousTransportName(path: string): boolean {
|
|
175
|
+
return /\.ts$/i.test(isRemote(path) ? new URL(path).pathname : path);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An extension that means a transport stream and nothing else. */
|
|
179
|
+
export function isTransportName(path: string): boolean {
|
|
180
|
+
const file = isRemote(path) ? new URL(path).pathname : path;
|
|
181
|
+
const dot = file.lastIndexOf(".");
|
|
182
|
+
return dot > 0 && TRANSPORT_NAMES.has(file.slice(dot).toLowerCase());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How many bytes are read to decide. Three packets at the widest spacing, and room to find the first. */
|
|
186
|
+
const SNIFF = 2048;
|
|
187
|
+
/**
|
|
188
|
+
* Packet sizes in the wild: 188 is MPEG-TS, 192 is what Blu-ray and a lot of
|
|
189
|
+
* recorders write (a four-byte arrival timestamp in front of each packet), 204
|
|
190
|
+
* is 188 with Reed-Solomon parity from a DVB card.
|
|
191
|
+
*/
|
|
192
|
+
const STRIDES = [188, 192, 204];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether these bytes are a transport stream: a 0x47 sync byte at the start of
|
|
196
|
+
* every packet.
|
|
197
|
+
*
|
|
198
|
+
* Three in a row at the same spacing, because one 0x47 in a file is the letter
|
|
199
|
+
* G. The first packet may not be at byte zero -- a recording cut mid-stream
|
|
200
|
+
* starts mid-packet -- so every offset within one packet is tried.
|
|
201
|
+
*/
|
|
202
|
+
export function sniffTransportStream(head: Buffer): boolean {
|
|
203
|
+
for (const stride of STRIDES) {
|
|
204
|
+
for (let start = 0; start < stride; start++) {
|
|
205
|
+
if (start + stride * 2 >= head.length) break;
|
|
206
|
+
if (head[start] !== 0x47) continue;
|
|
207
|
+
if (head[start + stride] === 0x47 && head[start + stride * 2] === 0x47) return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether the file at this path is a transport stream.
|
|
215
|
+
*
|
|
216
|
+
* Remembered, because the answer is asked once per snapshot per track and a
|
|
217
|
+
* library listing must not turn into a read per file per frame. A file that
|
|
218
|
+
* changes under us is a file being written, and a stale answer about it is a
|
|
219
|
+
* track that plays rather than a listing that stalls.
|
|
220
|
+
*/
|
|
221
|
+
const sniffed = new Map<string, boolean>();
|
|
222
|
+
const SNIFF_REMEMBERED = 5000;
|
|
223
|
+
|
|
224
|
+
export function looksLikeTransportStream(path: string): boolean {
|
|
225
|
+
if (isRemote(path)) return false;
|
|
226
|
+
const remembered = sniffed.get(path);
|
|
227
|
+
if (remembered !== undefined) return remembered;
|
|
228
|
+
let answer = false;
|
|
229
|
+
let fd: number | null = null;
|
|
230
|
+
try {
|
|
231
|
+
fd = openSync(path, "r");
|
|
232
|
+
const head = Buffer.alloc(SNIFF);
|
|
233
|
+
const read = readSync(fd, head, 0, SNIFF, 0);
|
|
234
|
+
answer = sniffTransportStream(head.subarray(0, read));
|
|
235
|
+
} catch {
|
|
236
|
+
answer = false;
|
|
237
|
+
} finally {
|
|
238
|
+
if (fd !== null) {
|
|
239
|
+
try {
|
|
240
|
+
closeSync(fd);
|
|
241
|
+
} catch {
|
|
242
|
+
// Closing a file that could not be opened is not a failure.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// A cap rather than a cache with eviction: a library of a million files
|
|
247
|
+
// must not be a million remembered answers, and forgetting costs one read.
|
|
248
|
+
if (sniffed.size >= SNIFF_REMEMBERED) sniffed.clear();
|
|
249
|
+
sniffed.set(path, answer);
|
|
250
|
+
return answer;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether this path is a transport stream, by name where the name is certain and by its bytes where it is not. */
|
|
254
|
+
export function isTransportStream(path: string): boolean {
|
|
255
|
+
if (isTransportName(path)) return true;
|
|
256
|
+
return isAmbiguousTransportName(path) && looksLikeTransportStream(path);
|
|
257
|
+
}
|
package/src/tickets.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tickets: a paid pass to one room.
|
|
3
|
+
*
|
|
4
|
+
* The same x402 machinery the crawler paywall runs on, pointed at a single
|
|
5
|
+
* event instead of at a whole server. A ticket is a signed token with an
|
|
6
|
+
* expiry and nothing else -- no table, no seat map, no order row -- which is
|
|
7
|
+
* what makes it usable from a client on another origin that holds no database.
|
|
8
|
+
*
|
|
9
|
+
* Three things are deliberate:
|
|
10
|
+
*
|
|
11
|
+
* - The signing secret is per event, derived from the deployment's secret and
|
|
12
|
+
* the event id. A ticket to Friday is therefore not a ticket to Saturday,
|
|
13
|
+
* and no revocation list is needed to make that true.
|
|
14
|
+
* - The money goes to the event's own `payTo`, so the performer is paid
|
|
15
|
+
* directly and nixamp is never in the middle of it.
|
|
16
|
+
* - A free event has no till at all: `sell` refuses rather than quoting 0.00,
|
|
17
|
+
* because a 402 nobody can satisfy is worse than an open door.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { createGateway, mintPass } from "@profullstack/x402-gateway";
|
|
21
|
+
import {
|
|
22
|
+
canPerformEvent,
|
|
23
|
+
isTicketed,
|
|
24
|
+
type LiveEvent,
|
|
25
|
+
} from "./live-events.ts";
|
|
26
|
+
|
|
27
|
+
/** Where a ticket rides. Same shape as the crawl pass, different name. */
|
|
28
|
+
export const TICKET_HEADER = "x-nixamp-ticket";
|
|
29
|
+
|
|
30
|
+
export interface TicketOptions {
|
|
31
|
+
/** A scoped CoinPay key with payments:create. Without it nothing sells. */
|
|
32
|
+
coinpayKey: string;
|
|
33
|
+
/** Pass signing secret. Defaults to the CoinPay key, as the gateway does. */
|
|
34
|
+
secret?: string;
|
|
35
|
+
/** The origin a buyer is quoted, which has to be one they can reach. */
|
|
36
|
+
site: string;
|
|
37
|
+
/** Injected by tests. */
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TicketOffer {
|
|
42
|
+
ticketed: boolean;
|
|
43
|
+
priceCents: number;
|
|
44
|
+
currency: string;
|
|
45
|
+
minutes: number;
|
|
46
|
+
header: string;
|
|
47
|
+
buy: string;
|
|
48
|
+
/** The x402 body, for a client that pays rather than reads. */
|
|
49
|
+
offer: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The ticket a request presents: its own header, or the query for a browser. */
|
|
53
|
+
export function ticketFrom(
|
|
54
|
+
headers: Record<string, string | string[] | undefined>,
|
|
55
|
+
url?: URL,
|
|
56
|
+
): string {
|
|
57
|
+
const raw = headers[TICKET_HEADER];
|
|
58
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
59
|
+
if (header && header.trim()) return header.trim();
|
|
60
|
+
return url?.searchParams.get("ticket")?.trim() ?? "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether this person still has to buy something before the room opens.
|
|
65
|
+
*
|
|
66
|
+
* Everyone on the stage is already in: an artist who had to buy a ticket to
|
|
67
|
+
* their own show would be a bug with a refund attached. So is the person who
|
|
68
|
+
* is holding a valid one.
|
|
69
|
+
*/
|
|
70
|
+
export function needsTicket(
|
|
71
|
+
event: LiveEvent,
|
|
72
|
+
accountId: string | undefined,
|
|
73
|
+
hasTicket: boolean,
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!isTicketed(event)) return false;
|
|
76
|
+
if (canPerformEvent(event, accountId)) return false;
|
|
77
|
+
return !hasTicket;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class Tickets {
|
|
81
|
+
private readonly built = new Map<string, ReturnType<typeof createGateway>>();
|
|
82
|
+
|
|
83
|
+
constructor(private readonly options: TicketOptions) {}
|
|
84
|
+
|
|
85
|
+
/** Whether tickets can actually be sold here, as opposed to merely priced. */
|
|
86
|
+
get enabled(): boolean {
|
|
87
|
+
return Boolean(this.options.coinpayKey);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The secret one event's tickets are signed with. Derived rather than
|
|
92
|
+
* stored: it has to be the same on every process that serves the event, and
|
|
93
|
+
* different for every event.
|
|
94
|
+
*/
|
|
95
|
+
private secretFor(event: LiveEvent): string {
|
|
96
|
+
const base = this.options.secret || this.options.coinpayKey || "nixamp-tickets";
|
|
97
|
+
return createHash("sha256").update(`${base}:ticket:${event.id}`).digest("hex");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private gatewayFor(event: LiveEvent) {
|
|
101
|
+
const site = this.options.site.replace(/\/$/, "");
|
|
102
|
+
const key = `${site}|${event.id}|${event.ticketPriceCents}|${event.ticketCurrency}|${event.ticketMinutes}|${event.payTo ?? ""}`;
|
|
103
|
+
const known = this.built.get(key);
|
|
104
|
+
if (known) return known;
|
|
105
|
+
const gateway = createGateway({
|
|
106
|
+
siteUrl: site,
|
|
107
|
+
siteName: event.title,
|
|
108
|
+
payTo: event.payTo ?? "",
|
|
109
|
+
priceCents: event.ticketPriceCents,
|
|
110
|
+
currency: event.ticketCurrency,
|
|
111
|
+
passMinutes: event.ticketMinutes,
|
|
112
|
+
coinpay: { apiKey: this.options.coinpayKey },
|
|
113
|
+
secret: this.secretFor(event),
|
|
114
|
+
header: TICKET_HEADER,
|
|
115
|
+
path: `/live/${event.slug}/tickets`,
|
|
116
|
+
// Nothing here is about who is asking: a ticket is a ticket whatever
|
|
117
|
+
// the user agent says it is.
|
|
118
|
+
isPaidAgent: () => true,
|
|
119
|
+
benefits: [`Watch ${event.title} live, and the replay for ${event.ticketMinutes} minutes.`],
|
|
120
|
+
...(this.options.fetch ? { fetch: this.options.fetch } : {}),
|
|
121
|
+
});
|
|
122
|
+
// Bounded, so a directory of thousands of events cannot grow a gateway
|
|
123
|
+
// per event and keep them all.
|
|
124
|
+
if (this.built.size > 200) this.built.clear();
|
|
125
|
+
this.built.set(key, gateway);
|
|
126
|
+
return gateway;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What a ticket costs and how to buy one, without answering a request. */
|
|
130
|
+
offer(event: LiveEvent): TicketOffer {
|
|
131
|
+
const site = this.options.site.replace(/\/$/, "");
|
|
132
|
+
return {
|
|
133
|
+
ticketed: isTicketed(event),
|
|
134
|
+
priceCents: event.ticketPriceCents,
|
|
135
|
+
currency: event.ticketCurrency,
|
|
136
|
+
minutes: event.ticketMinutes,
|
|
137
|
+
header: TICKET_HEADER,
|
|
138
|
+
buy: `${site}/live/${event.slug}/tickets`,
|
|
139
|
+
offer: isTicketed(event) ? this.gatewayFor(event).offer(1) : { x402Version: 2, accepts: [] },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A ticket nobody paid for: the guest list.
|
|
145
|
+
*
|
|
146
|
+
* Every venue has one, and a show that cannot comp the press, the support
|
|
147
|
+
* act's friends or a refunded buyer is a show whose only answer to a
|
|
148
|
+
* problem is "pay again". It is the same signed token a sale produces, so
|
|
149
|
+
* nothing downstream has to know which kind it is holding.
|
|
150
|
+
*/
|
|
151
|
+
async mint(event: LiveEvent, minutes = event.ticketMinutes): Promise<string> {
|
|
152
|
+
const now = Math.floor(Date.now() / 1000);
|
|
153
|
+
const pass = await mintPass({
|
|
154
|
+
secret: this.secretFor(event),
|
|
155
|
+
ref: `comp:${event.id}`,
|
|
156
|
+
expiresAt: now + Math.max(1, Math.floor(minutes)) * 60,
|
|
157
|
+
now,
|
|
158
|
+
});
|
|
159
|
+
return pass.token;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether this token is a ticket this deployment minted for this event. */
|
|
163
|
+
async holds(event: LiveEvent, token: string): Promise<boolean> {
|
|
164
|
+
if (!token || !isTicketed(event)) return false;
|
|
165
|
+
return this.gatewayFor(event).verifyPass(token);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Sell one. Handed the buyer's request, because the payment proof travels
|
|
170
|
+
* in its headers; answers 402 with the offer, or 200 with the ticket.
|
|
171
|
+
*/
|
|
172
|
+
async sell(event: LiveEvent, request: Request): Promise<Response> {
|
|
173
|
+
if (!isTicketed(event)) {
|
|
174
|
+
return Response.json({ error: "this event is free; no ticket is needed" }, { status: 409 });
|
|
175
|
+
}
|
|
176
|
+
if (!this.enabled) {
|
|
177
|
+
return Response.json({ error: "ticket sales are not switched on here" }, { status: 503 });
|
|
178
|
+
}
|
|
179
|
+
return this.gatewayFor(event).sell(request);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Read a Tickets configuration out of the environment. */
|
|
184
|
+
export function ticketsFromEnv(
|
|
185
|
+
site: string,
|
|
186
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
187
|
+
): TicketOptions | null {
|
|
188
|
+
const coinpayKey = env["COINPAY_X402_KEY"] ?? "";
|
|
189
|
+
if (!coinpayKey) return null;
|
|
190
|
+
return {
|
|
191
|
+
coinpayKey,
|
|
192
|
+
site,
|
|
193
|
+
...(env["NIXAMP_TICKET_SECRET"] ? { secret: env["NIXAMP_TICKET_SECRET"] } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/tokens.ts
CHANGED
|
@@ -28,8 +28,13 @@ export const TOKEN_PREFIX = "nxa_";
|
|
|
28
28
|
/** How long a sign-in lasts. Long, because signing in on a television is work. */
|
|
29
29
|
export const SESSION_DAYS = 90;
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A session ends; a token a person made for a script does not, unless asked;
|
|
33
|
+
* and one another site holds on your behalf is an `oauth` token, which is the
|
|
34
|
+
* same thing with a client's name on it so it can be listed and withdrawn as
|
|
35
|
+
* "bittorrented.com" rather than as an anonymous string.
|
|
36
|
+
*/
|
|
37
|
+
export type TokenKind = "session" | "cli" | "oauth";
|
|
33
38
|
|
|
34
39
|
export interface TokenRecord {
|
|
35
40
|
id: string;
|
|
@@ -113,7 +118,7 @@ function toRecord(row: Record<string, unknown>): TokenRecord {
|
|
|
113
118
|
return {
|
|
114
119
|
id: String(row["id"] ?? ""),
|
|
115
120
|
name: String(row["name"] ?? ""),
|
|
116
|
-
kind: row["kind"] === "cli" ? "
|
|
121
|
+
kind: row["kind"] === "cli" || row["kind"] === "oauth" ? (row["kind"] as TokenKind) : "session",
|
|
117
122
|
createdAt: asTime(row["created_at"]) ?? 0,
|
|
118
123
|
expiresAt: asTime(row["expires_at"]),
|
|
119
124
|
lastUsedAt: asTime(row["last_used_at"]),
|
|
@@ -233,6 +238,48 @@ export class Tokens {
|
|
|
233
238
|
return rows.length > 0;
|
|
234
239
|
}
|
|
235
240
|
|
|
241
|
+
/**
|
|
242
|
+
* One token, by its id, whatever kind it is.
|
|
243
|
+
*
|
|
244
|
+
* `revoke` is scoped to the owner because it answers a person deleting
|
|
245
|
+
* their own token. This one is for the server withdrawing a token it
|
|
246
|
+
* issued -- an access token whose refresh token was just rotated, or the
|
|
247
|
+
* whole family behind a code somebody replayed -- where there is no owner
|
|
248
|
+
* doing the asking.
|
|
249
|
+
*/
|
|
250
|
+
async revokeById(id: string): Promise<boolean> {
|
|
251
|
+
await this.ensure();
|
|
252
|
+
const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 RETURNING id`, [id]);
|
|
253
|
+
return rows.length > 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The record behind a token, without counting it as a use.
|
|
258
|
+
*
|
|
259
|
+
* `verify` is the hot path and stamps last_used_at; this answers the same
|
|
260
|
+
* question for a caller that needs the row itself -- which client holds
|
|
261
|
+
* this, and what it was granted -- and leaves the timestamp alone.
|
|
262
|
+
*/
|
|
263
|
+
async inspect(value: string): Promise<(TokenRecord & { account: Account }) | null> {
|
|
264
|
+
const parts = splitToken(value);
|
|
265
|
+
if (parts === null) return null;
|
|
266
|
+
await this.ensure();
|
|
267
|
+
const { rows } = await this.db.query(
|
|
268
|
+
`SELECT id, user_id, email, kind, name, secret_hash, created_at, expires_at, last_used_at
|
|
269
|
+
FROM ${TABLE} WHERE id = $1`,
|
|
270
|
+
[parts.id],
|
|
271
|
+
);
|
|
272
|
+
const row = rows[0];
|
|
273
|
+
if (!row) return null;
|
|
274
|
+
if (!sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret))) return null;
|
|
275
|
+
const expiresAt = asTime(row["expires_at"]);
|
|
276
|
+
if (expiresAt !== null && expiresAt <= this.now()) return null;
|
|
277
|
+
return {
|
|
278
|
+
...toRecord(row),
|
|
279
|
+
account: { id: String(row["user_id"] ?? ""), email: String(row["email"] ?? "") },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
236
283
|
/** Signing out of one place should not sign you out of the build server. */
|
|
237
284
|
async revokeToken(value: string): Promise<boolean> {
|
|
238
285
|
const parts = splitToken(value);
|