nixamp 0.8.0 → 0.9.4

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.
@@ -0,0 +1,112 @@
1
+ import type { DnsZone } from "./dns.ts";
2
+ import type { Queryable } from "./follows.ts";
3
+ export interface Issued {
4
+ /** PEM, the full chain. */
5
+ cert: string;
6
+ /** PEM. */
7
+ key: string;
8
+ /** Epoch milliseconds. */
9
+ expiresAt: number;
10
+ }
11
+ /** Where a DNS-01 challenge gets written, and unwritten. */
12
+ export interface Challenge {
13
+ set(host: string, value: string): Promise<void>;
14
+ clear(host: string, value: string): Promise<void>;
15
+ }
16
+ /** Something that turns a list of names into a certificate. ACME, in production. */
17
+ export interface Issuer {
18
+ issue(names: string[], challenge: Challenge): Promise<Issued>;
19
+ }
20
+ export interface AcmeIssuerOptions {
21
+ /** acme.directory.letsencrypt.production, or .staging while trying things out. */
22
+ directoryUrl: string;
23
+ /** The account contact Let's Encrypt writes to about expiry. */
24
+ email: string;
25
+ /** The account's private key, PEM. Kept by the caller; see Certs.accountKey. */
26
+ accountKey: () => Promise<string>;
27
+ /**
28
+ * How long to wait after writing the TXT before letting the CA look for it.
29
+ * A registrar's API answers at once; the world's resolvers do not.
30
+ */
31
+ propagationMs?: number;
32
+ sleep?: (ms: number) => Promise<void>;
33
+ }
34
+ /** A minute is what Porkbun's 600-second floor tends to cost in practice. */
35
+ export declare const DEFAULT_PROPAGATION_MS = 60000;
36
+ /** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
37
+ export declare const RENEW_BEFORE_MS: number;
38
+ /** A failed order is worth trying again, but not every time somebody asks. */
39
+ export declare const RETRY_AFTER_MS: number;
40
+ /**
41
+ * Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
42
+ * proved any other way, and the zone is the one thing nixamp.com does hold.
43
+ */
44
+ export declare class AcmeIssuer implements Issuer {
45
+ private readonly options;
46
+ constructor(options: AcmeIssuerOptions);
47
+ issue(names: string[], challenge: Challenge): Promise<Issued>;
48
+ }
49
+ export type CertState = {
50
+ status: "ready";
51
+ cert: string;
52
+ key: string;
53
+ expiresAt: number;
54
+ renewing: boolean;
55
+ } | {
56
+ status: "issuing";
57
+ since: number;
58
+ } | {
59
+ status: "failed";
60
+ error: string;
61
+ at: number;
62
+ } | {
63
+ status: "none";
64
+ };
65
+ export interface CertsOptions {
66
+ renewBeforeMs?: number;
67
+ retryAfterMs?: number;
68
+ now?: () => number;
69
+ log?: (line: string) => void;
70
+ }
71
+ /**
72
+ * The certificates, one per handle, and the work of getting them.
73
+ *
74
+ * The rows are the truth and survive a restart; what is in flight lives in
75
+ * memory, because nixamp.com is one process and an order interrupted by a
76
+ * deploy is simply started again when next asked for.
77
+ */
78
+ export declare class Certs {
79
+ private readonly db;
80
+ private readonly zone;
81
+ private readonly issuer;
82
+ private ready;
83
+ private readonly work;
84
+ private readonly renewBeforeMs;
85
+ private readonly retryAfterMs;
86
+ private readonly now;
87
+ private readonly log;
88
+ constructor(db: Queryable, zone: DnsZone, issuer: Issuer, options?: CertsOptions);
89
+ private ensure;
90
+ /**
91
+ * The ACME account key, made once and kept. Losing it would not lose any
92
+ * certificate, but every renewal would register a new account, and Let's
93
+ * Encrypt rate-limits those too.
94
+ */
95
+ accountKey(): Promise<string>;
96
+ /** The names one handle's certificate covers: every server it has, and the handle itself. */
97
+ namesFor(handle: string): string[];
98
+ private stored;
99
+ /**
100
+ * A handle's certificate, or what is being done about the lack of one.
101
+ *
102
+ * Never blocks on the CA. With nothing stored, or something failed long
103
+ * enough ago to be worth another go, an order starts in the background and
104
+ * the answer is "issuing". With one stored that is near its end, the answer
105
+ * is still the old one -- it is valid -- and a renewal starts alongside.
106
+ */
107
+ forHandle(handle: string): Promise<CertState>;
108
+ /** Order in the background, and remember how it went. */
109
+ private begin;
110
+ /** For tests and shutdown: whatever order is in flight for a handle. */
111
+ settle(handle: string): Promise<void>;
112
+ }
package/dist/certs.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Certificates: one wildcard per handle, issued and renewed by nixamp.com.
3
+ *
4
+ * A server that has been given `server2.chovy.nixamp.com` still answers over
5
+ * plain http until it has a certificate, and getting one by hand is exactly
6
+ * the manual step this exists to remove. So nixamp.com, which already holds
7
+ * the zone, does the whole thing: it orders `*.chovy.nixamp.com` and
8
+ * `chovy.nixamp.com` from Let's Encrypt, answers the DNS-01 challenge by
9
+ * writing the `_acme-challenge` TXT record itself, keeps the result in the
10
+ * database, renews it when it gets close to expiry, and hands cert and key
11
+ * to that account's servers over the authenticated API.
12
+ *
13
+ * One wildcard per handle, not one certificate per server. Let's Encrypt
14
+ * rate-limits per registered domain -- nixamp.com, for all of us -- and every
15
+ * server an account starts is covered by the wildcard it already has.
16
+ *
17
+ * The ACME dance takes minutes, mostly waiting for DNS to propagate, so it is
18
+ * never done inside a request. `forHandle` starts it in the background and
19
+ * answers "issuing"; the server asks again in a while.
20
+ */
21
+ import acme from "acme-client";
22
+ /** A minute is what Porkbun's 600-second floor tends to cost in practice. */
23
+ export const DEFAULT_PROPAGATION_MS = 60_000;
24
+ /** Renew inside the last thirty days, which is what Let's Encrypt recommends. */
25
+ export const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
26
+ /** A failed order is worth trying again, but not every time somebody asks. */
27
+ export const RETRY_AFTER_MS = 60 * 60 * 1000;
28
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
29
+ /**
30
+ * Let's Encrypt, through acme-client, by DNS-01 only: a wildcard cannot be
31
+ * proved any other way, and the zone is the one thing nixamp.com does hold.
32
+ */
33
+ export class AcmeIssuer {
34
+ options;
35
+ constructor(options) {
36
+ this.options = options;
37
+ }
38
+ async issue(names, challenge) {
39
+ const [first, ...rest] = names;
40
+ if (!first)
41
+ throw new Error("nothing to issue a certificate for");
42
+ const propagationMs = this.options.propagationMs ?? DEFAULT_PROPAGATION_MS;
43
+ const sleep = this.options.sleep ?? wait;
44
+ const client = new acme.Client({
45
+ directoryUrl: this.options.directoryUrl,
46
+ accountKey: await this.options.accountKey(),
47
+ });
48
+ // A fresh key per certificate. Reusing the account key for the
49
+ // certificate would make one leak two.
50
+ const [key, csr] = await acme.crypto.createCsr({ commonName: first, altNames: [first, ...rest] });
51
+ const cert = await client.auto({
52
+ csr,
53
+ email: this.options.email,
54
+ termsOfServiceAgreed: true,
55
+ challengePriority: ["dns-01"],
56
+ // acme-client would otherwise resolve the TXT itself before telling the
57
+ // CA to, and from wherever nixamp.com runs that lookup can lag the CA's.
58
+ skipChallengeVerification: true,
59
+ challengeCreateFn: async (authz, _challenge, keyAuthorization) => {
60
+ await challenge.set(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
61
+ await sleep(propagationMs);
62
+ },
63
+ challengeRemoveFn: async (authz, _challenge, keyAuthorization) => {
64
+ await challenge.clear(`_acme-challenge.${authz.identifier.value}`, keyAuthorization);
65
+ },
66
+ });
67
+ const info = acme.crypto.readCertificateInfo(cert);
68
+ return { cert, key: key.toString(), expiresAt: info.notAfter.getTime() };
69
+ }
70
+ }
71
+ const SCHEMA = `
72
+ CREATE TABLE IF NOT EXISTS certs (
73
+ handle TEXT PRIMARY KEY,
74
+ cert TEXT NOT NULL,
75
+ key TEXT NOT NULL,
76
+ expires_at TIMESTAMPTZ NOT NULL,
77
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
78
+ );
79
+ CREATE TABLE IF NOT EXISTS acme_account (
80
+ id INTEGER PRIMARY KEY,
81
+ key_pem TEXT NOT NULL
82
+ );
83
+ `;
84
+ /**
85
+ * The certificates, one per handle, and the work of getting them.
86
+ *
87
+ * The rows are the truth and survive a restart; what is in flight lives in
88
+ * memory, because nixamp.com is one process and an order interrupted by a
89
+ * deploy is simply started again when next asked for.
90
+ */
91
+ export class Certs {
92
+ db;
93
+ zone;
94
+ issuer;
95
+ ready = null;
96
+ work = new Map();
97
+ renewBeforeMs;
98
+ retryAfterMs;
99
+ now;
100
+ log;
101
+ constructor(db, zone, issuer, options = {}) {
102
+ this.db = db;
103
+ this.zone = zone;
104
+ this.issuer = issuer;
105
+ this.renewBeforeMs = options.renewBeforeMs ?? RENEW_BEFORE_MS;
106
+ this.retryAfterMs = options.retryAfterMs ?? RETRY_AFTER_MS;
107
+ this.now = options.now ?? Date.now;
108
+ this.log = options.log ?? (() => undefined);
109
+ }
110
+ async ensure() {
111
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
112
+ await this.ready;
113
+ }
114
+ /**
115
+ * The ACME account key, made once and kept. Losing it would not lose any
116
+ * certificate, but every renewal would register a new account, and Let's
117
+ * Encrypt rate-limits those too.
118
+ */
119
+ async accountKey() {
120
+ await this.ensure();
121
+ const { rows } = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
122
+ const kept = rows[0]?.["key_pem"];
123
+ if (typeof kept === "string" && kept !== "")
124
+ return kept;
125
+ const made = (await acme.crypto.createPrivateKey()).toString();
126
+ await this.db.query("INSERT INTO acme_account (id, key_pem) VALUES (1, $1) ON CONFLICT (id) DO NOTHING", [made]);
127
+ // Somebody else may have won the race to insert; theirs is the account now.
128
+ const again = await this.db.query("SELECT key_pem FROM acme_account WHERE id = 1");
129
+ const winner = again.rows[0]?.["key_pem"];
130
+ return typeof winner === "string" && winner !== "" ? winner : made;
131
+ }
132
+ /** The names one handle's certificate covers: every server it has, and the handle itself. */
133
+ namesFor(handle) {
134
+ return [`*.${handle}.${this.zone.zone}`, `${handle}.${this.zone.zone}`];
135
+ }
136
+ async stored(handle) {
137
+ await this.ensure();
138
+ const { rows } = await this.db.query("SELECT cert, key, expires_at FROM certs WHERE handle = $1", [handle]);
139
+ const row = rows[0];
140
+ if (!row)
141
+ return null;
142
+ const cert = row["cert"];
143
+ const key = row["key"];
144
+ const expires = row["expires_at"];
145
+ if (typeof cert !== "string" || typeof key !== "string")
146
+ return null;
147
+ const expiresAt = expires instanceof Date ? expires.getTime() : new Date(String(expires)).getTime();
148
+ if (!Number.isFinite(expiresAt))
149
+ return null;
150
+ return { cert, key, expiresAt };
151
+ }
152
+ /**
153
+ * A handle's certificate, or what is being done about the lack of one.
154
+ *
155
+ * Never blocks on the CA. With nothing stored, or something failed long
156
+ * enough ago to be worth another go, an order starts in the background and
157
+ * the answer is "issuing". With one stored that is near its end, the answer
158
+ * is still the old one -- it is valid -- and a renewal starts alongside.
159
+ */
160
+ async forHandle(handle) {
161
+ const kept = await this.stored(handle);
162
+ const inFlight = this.work.get(handle);
163
+ if (kept && kept.expiresAt > this.now()) {
164
+ const renewing = kept.expiresAt - this.now() < this.renewBeforeMs;
165
+ if (renewing && !(inFlight?.kind === "issuing"))
166
+ this.begin(handle, "renewal");
167
+ return {
168
+ status: "ready",
169
+ cert: kept.cert,
170
+ key: kept.key,
171
+ expiresAt: kept.expiresAt,
172
+ renewing: renewing || inFlight?.kind === "issuing",
173
+ };
174
+ }
175
+ if (inFlight?.kind === "issuing")
176
+ return { status: "issuing", since: inFlight.since };
177
+ if (inFlight?.kind === "failed" && this.now() - inFlight.at < this.retryAfterMs) {
178
+ return { status: "failed", error: inFlight.error, at: inFlight.at };
179
+ }
180
+ const started = this.begin(handle, kept ? "replacement of an expired certificate" : "first certificate");
181
+ return { status: "issuing", since: started.since };
182
+ }
183
+ /** Order in the background, and remember how it went. */
184
+ begin(handle, why) {
185
+ const since = this.now();
186
+ const names = this.namesFor(handle);
187
+ this.log(` Ordering a certificate for ${names.join(" and ")} (${why}).`);
188
+ const done = this.issuer
189
+ .issue(names, {
190
+ set: (host, value) => this.zone.add(host, "TXT", value, 600),
191
+ clear: (host, value) => this.zone.remove(host, "TXT", value),
192
+ })
193
+ .then(async (issued) => {
194
+ await this.ensure();
195
+ await this.db.query(`INSERT INTO certs (handle, cert, key, expires_at, issued_at) VALUES ($1, $2, $3, $4, now())
196
+ ON CONFLICT (handle) DO UPDATE SET cert = EXCLUDED.cert, key = EXCLUDED.key,
197
+ expires_at = EXCLUDED.expires_at, issued_at = now()`, [handle, issued.cert, issued.key, new Date(issued.expiresAt).toISOString()]);
198
+ const days = Math.max(0, Math.round((issued.expiresAt - this.now()) / (24 * 60 * 60 * 1000)));
199
+ this.log(` Certificate for *.${handle}.${this.zone.zone} issued, good for ${days} days.`);
200
+ this.work.delete(handle);
201
+ })
202
+ .catch((error) => {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ this.log(` Certificate for *.${handle}.${this.zone.zone} failed: ${message}`);
205
+ this.work.set(handle, { kind: "failed", error: message, at: this.now() });
206
+ });
207
+ const work = { kind: "issuing", since, done };
208
+ this.work.set(handle, work);
209
+ return work;
210
+ }
211
+ /** For tests and shutdown: whatever order is in flight for a handle. */
212
+ async settle(handle) {
213
+ const inFlight = this.work.get(handle);
214
+ if (inFlight?.kind === "issuing")
215
+ await inFlight.done;
216
+ }
217
+ }
@@ -51,6 +51,14 @@ export interface Listing {
51
51
  * an older nixamp that only knows about `url`.
52
52
  */
53
53
  audio: string;
54
+ /**
55
+ * The link that administers the server, as the publisher announced it.
56
+ *
57
+ * Kept so the owner can open their own machine as its administrator from
58
+ * the directory, and handed out to nobody else: the listing route strips it
59
+ * for anyone but the account that owns the listing.
60
+ */
61
+ admin: string;
54
62
  tracks: number;
55
63
  nowPlaying: string;
56
64
  /**
@@ -96,6 +104,8 @@ export interface Announcement {
96
104
  url: string;
97
105
  /** Where the audio actually is. See `Listing.audio`. */
98
106
  audio?: string;
107
+ /** The admin share link, same origin as `url`. Kept for the owner alone. */
108
+ admin?: string;
99
109
  tracks: number;
100
110
  nowPlaying: string;
101
111
  /** Absent from an older publisher, which is read as "unknown, say playing". */
package/dist/directory.js CHANGED
@@ -68,6 +68,10 @@ export function parseAnnouncement(input) {
68
68
  const offered = typeof record["audio"] === "string" ? record["audio"] : "";
69
69
  const parsed = offered ? publishable(offered) : null;
70
70
  const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
71
+ // The admin link is held to the same rule: it names this server or nothing.
72
+ const adminOffered = typeof record["admin"] === "string" ? record["admin"] : "";
73
+ const adminParsed = adminOffered ? publishable(adminOffered) : null;
74
+ const admin = adminParsed !== null && adminParsed.origin === listen.origin ? adminOffered : "";
71
75
  const name = clean(record["name"], MAX_NAME);
72
76
  const tracks = Number(record["tracks"]);
73
77
  return {
@@ -75,6 +79,7 @@ export function parseAnnouncement(input) {
75
79
  name: name || "a nixamp",
76
80
  url,
77
81
  ...(audio ? { audio } : {}),
82
+ ...(admin ? { admin } : {}),
78
83
  tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
79
84
  nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
80
85
  ...(typeof record["playing"] === "boolean" ? { playing: record["playing"] } : {}),
@@ -167,6 +172,7 @@ export class Directory {
167
172
  // older publisher renewing an entry should not blank the address the
168
173
  // phone line is playing from.
169
174
  audio: announcement.audio ?? existing?.audio ?? "",
175
+ admin: announcement.admin ?? existing?.admin ?? "",
170
176
  tracks: announcement.tracks,
171
177
  nowPlaying: announcement.nowPlaying,
172
178
  // An older publisher says nothing about either; "playing" keeps what a
package/dist/dns.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export type RecordType = "A" | "AAAA" | "TXT";
2
+ export interface DnsRecord {
3
+ id: string;
4
+ /** The full hostname, e.g. "server2.chovy.nixamp.com". */
5
+ host: string;
6
+ type: RecordType;
7
+ content: string;
8
+ ttl: number;
9
+ }
10
+ export interface DnsZone {
11
+ /** The apex this zone answers for: "nixamp.com". */
12
+ readonly zone: string;
13
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
14
+ /** Afterwards exactly one record of that host and type exists, with this content. */
15
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
16
+ /** One more. An ACME order for a wildcard and its apex wants two TXT at once. */
17
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
18
+ /** All of host and type, or only those whose content matches. */
19
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
20
+ }
21
+ /** 2026, not 1998: an address is either family, and both are first class. */
22
+ export declare function isIPv4(value: unknown): boolean;
23
+ export declare function isIPv6(value: unknown): boolean;
24
+ /** Porkbun's floor. Anything lower is silently raised, so it is raised here, loudly. */
25
+ export declare const MIN_TTL = 600;
26
+ /**
27
+ * A zone kept in memory. For tests, and for a nixamp started without registrar
28
+ * keys, where a name can be handed out and simply will not resolve -- which the
29
+ * operator is told about elsewhere, rather than crashing here.
30
+ */
31
+ export declare class MemoryZone implements DnsZone {
32
+ readonly zone: string;
33
+ readonly records: DnsRecord[];
34
+ private sequence;
35
+ constructor(zone: string);
36
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
37
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
38
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
39
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
40
+ }
41
+ /**
42
+ * Porkbun's v3 DNS API.
43
+ *
44
+ * Every call is a POST carrying both halves of the key in the body -- the
45
+ * field is `secretapikey`, which the API itself names as the usual mistake --
46
+ * and the subdomain is the host with the zone taken off: "server2.chovy" for
47
+ * "server2.chovy.nixamp.com", and nothing at all for the apex.
48
+ */
49
+ export declare class Porkbun implements DnsZone {
50
+ readonly zone: string;
51
+ private readonly apiKey;
52
+ private readonly secretApiKey;
53
+ private readonly send;
54
+ private readonly base;
55
+ constructor(zone: string, apiKey: string, secretApiKey: string, send?: typeof fetch, base?: string);
56
+ /** The part before the zone, or "" for the zone itself. */
57
+ private sub;
58
+ private call;
59
+ list(host: string, type: RecordType): Promise<DnsRecord[]>;
60
+ add(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
61
+ set(host: string, type: RecordType, content: string, ttl?: number): Promise<void>;
62
+ remove(host: string, type: RecordType, content?: string): Promise<void>;
63
+ }
package/dist/dns.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The zone: where a server's name is written down.
3
+ *
4
+ * nixamp.com hands every signed-in account's servers a name under
5
+ * `<label>.<handle>.nixamp.com`, and a name is only a name once it resolves.
6
+ * The records live at Porkbun, behind an API key that stays on nixamp.com:
7
+ * a server asks nixamp.com for its name, and nixamp.com is the only thing
8
+ * that ever talks to the registrar. The certificate module needs the same
9
+ * zone for its `_acme-challenge` TXT records, which is why TXT is here too.
10
+ *
11
+ * Behind an interface, because a test wants a zone it can read back without
12
+ * a network, and a nixamp started without keys still wants the rest of the
13
+ * code to run.
14
+ */
15
+ import { isIP } from "node:net";
16
+ /** 2026, not 1998: an address is either family, and both are first class. */
17
+ export function isIPv4(value) {
18
+ return typeof value === "string" && isIP(value) === 4;
19
+ }
20
+ export function isIPv6(value) {
21
+ return typeof value === "string" && isIP(value) === 6;
22
+ }
23
+ /** Porkbun's floor. Anything lower is silently raised, so it is raised here, loudly. */
24
+ export const MIN_TTL = 600;
25
+ function ttlOf(ttl) {
26
+ return Math.max(MIN_TTL, Math.floor(ttl ?? MIN_TTL));
27
+ }
28
+ /**
29
+ * A zone kept in memory. For tests, and for a nixamp started without registrar
30
+ * keys, where a name can be handed out and simply will not resolve -- which the
31
+ * operator is told about elsewhere, rather than crashing here.
32
+ */
33
+ export class MemoryZone {
34
+ zone;
35
+ records = [];
36
+ sequence = 0;
37
+ constructor(zone) {
38
+ this.zone = zone;
39
+ }
40
+ async list(host, type) {
41
+ return this.records.filter((record) => record.host === host && record.type === type);
42
+ }
43
+ async set(host, type, content, ttl) {
44
+ await this.remove(host, type);
45
+ await this.add(host, type, content, ttl);
46
+ }
47
+ async add(host, type, content, ttl) {
48
+ this.records.push({ id: String(++this.sequence), host, type, content, ttl: ttlOf(ttl) });
49
+ }
50
+ async remove(host, type, content) {
51
+ for (let i = this.records.length - 1; i >= 0; i--) {
52
+ const record = this.records[i];
53
+ if (record.host !== host || record.type !== type)
54
+ continue;
55
+ if (content !== undefined && record.content !== content)
56
+ continue;
57
+ this.records.splice(i, 1);
58
+ }
59
+ }
60
+ }
61
+ /** How long to wait on the registrar before deciding it is not answering. */
62
+ const PORKBUN_TIMEOUT_MS = 20_000;
63
+ /**
64
+ * Porkbun's v3 DNS API.
65
+ *
66
+ * Every call is a POST carrying both halves of the key in the body -- the
67
+ * field is `secretapikey`, which the API itself names as the usual mistake --
68
+ * and the subdomain is the host with the zone taken off: "server2.chovy" for
69
+ * "server2.chovy.nixamp.com", and nothing at all for the apex.
70
+ */
71
+ export class Porkbun {
72
+ zone;
73
+ apiKey;
74
+ secretApiKey;
75
+ send;
76
+ base;
77
+ constructor(zone, apiKey, secretApiKey, send = fetch, base = "https://api.porkbun.com/api/json/v3") {
78
+ this.zone = zone;
79
+ this.apiKey = apiKey;
80
+ this.secretApiKey = secretApiKey;
81
+ this.send = send;
82
+ this.base = base;
83
+ }
84
+ /** The part before the zone, or "" for the zone itself. */
85
+ sub(host) {
86
+ const suffix = `.${this.zone}`;
87
+ if (host === this.zone)
88
+ return "";
89
+ if (!host.endsWith(suffix))
90
+ throw new Error(`${host} is not in ${this.zone}`);
91
+ return host.slice(0, -suffix.length);
92
+ }
93
+ async call(path, fields = {}) {
94
+ let answer;
95
+ try {
96
+ answer = await this.send(`${this.base}${path}`, {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify({ apikey: this.apiKey, secretapikey: this.secretApiKey, ...fields }),
100
+ signal: AbortSignal.timeout(PORKBUN_TIMEOUT_MS),
101
+ });
102
+ }
103
+ catch (error) {
104
+ throw new Error(`Porkbun did not answer: ${error.message}`);
105
+ }
106
+ let body;
107
+ try {
108
+ body = (await answer.json());
109
+ }
110
+ catch {
111
+ throw new Error(`Porkbun answered ${answer.status} with something that is not JSON`);
112
+ }
113
+ if (!answer.ok || body.status !== "SUCCESS") {
114
+ throw new Error(`Porkbun refused: ${body.message ?? `HTTP ${answer.status}`}`);
115
+ }
116
+ return body;
117
+ }
118
+ async list(host, type) {
119
+ // A trailing slash with nothing after it is how the apex is asked for.
120
+ const body = await this.call(`/dns/retrieveByNameType/${this.zone}/${type}/${this.sub(host)}`);
121
+ return (body.records ?? [])
122
+ .filter((record) => (record.type ?? type) === type)
123
+ .map((record) => ({
124
+ id: String(record.id ?? ""),
125
+ host: record.name ?? host,
126
+ type,
127
+ content: record.content ?? "",
128
+ ttl: Number(record.ttl ?? MIN_TTL) || MIN_TTL,
129
+ }));
130
+ }
131
+ async add(host, type, content, ttl) {
132
+ await this.call(`/dns/create/${this.zone}`, {
133
+ name: this.sub(host),
134
+ type,
135
+ content,
136
+ ttl: String(ttlOf(ttl)),
137
+ });
138
+ }
139
+ async set(host, type, content, ttl) {
140
+ const existing = await this.list(host, type);
141
+ const [first, ...extras] = existing;
142
+ if (first === undefined) {
143
+ await this.add(host, type, content, ttl);
144
+ return;
145
+ }
146
+ // Edit rather than delete-and-create: the name never has a moment with no
147
+ // record at all, which for an A record is a moment nobody can connect.
148
+ // Unless nothing would change: Porkbun refuses an edit that edits nothing
149
+ // ("We were unable to edit the DNS record"), and a server announcing the
150
+ // address it already has is the ordinary case, not an error.
151
+ if (first.content !== content || first.ttl !== ttlOf(ttl)) {
152
+ await this.call(`/dns/edit/${this.zone}/${first.id}`, {
153
+ name: this.sub(host),
154
+ type,
155
+ content,
156
+ ttl: String(ttlOf(ttl)),
157
+ });
158
+ }
159
+ for (const extra of extras) {
160
+ await this.call(`/dns/delete/${this.zone}/${extra.id}`);
161
+ }
162
+ }
163
+ async remove(host, type, content) {
164
+ const existing = await this.list(host, type);
165
+ for (const record of existing) {
166
+ if (content !== undefined && record.content !== content)
167
+ continue;
168
+ await this.call(`/dns/delete/${this.zone}/${record.id}`);
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,36 @@
1
+ export interface Config {
2
+ /** The folder the daemon serves when it is not told which. */
3
+ library?: string;
4
+ }
5
+ export declare function configPath(): string;
6
+ export declare function readConfig(): Config;
7
+ export declare function writeConfig(patch: Partial<Config>): void;
8
+ /** The saved library, or "" when nobody has said yet. */
9
+ export declare function readLibrary(): string;
10
+ export declare function writeLibrary(path: string): void;
11
+ /**
12
+ * Why a folder must not be served, or "" when it may be.
13
+ *
14
+ * Judged on the resolved path, so `~/..` and `/home/me/./` do not slip past.
15
+ * A hidden folder inside home is refused because that is where `.ssh`,
16
+ * `.config` and nixamp's own keys are; a hidden folder elsewhere is somebody's
17
+ * deliberate choice.
18
+ */
19
+ export declare function forbiddenLibrary(path: string, home?: string): string;
20
+ /** Folders a person is likely to mean, in the order they are likely to mean them. */
21
+ export declare function suggestedLibraries(home?: string): string[];
22
+ /** One line from the terminal. Split out so the question can be tested. */
23
+ export declare function askLine(question: string): Promise<string>;
24
+ /**
25
+ * Ask where the media is, refusing the answers that must be refused, and
26
+ * keep the answer. Empty when the person gave up or gave nothing usable
27
+ * three times.
28
+ */
29
+ export declare function askLibrary(ask?: (question: string) => Promise<string>, home?: string, say?: (line: string) => void): Promise<string>;
30
+ /**
31
+ * The library to use: the saved one, or -- when there is somebody to ask --
32
+ * the one they name now. "" when there is neither.
33
+ */
34
+ export declare function chooseLibrary(interactive: boolean, ask?: (question: string) => Promise<string>): Promise<string>;
35
+ /** `nixamp library [folder]`: say where the media is, or set it. */
36
+ export declare function libraryCommand(argv: string[]): Promise<number>;