@shbernal/buzon 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +151 -2
- package/bin/buzon +2 -0
- package/package.json +47 -4
- package/skills/README.md +15 -0
- package/skills/buzon/SKILL.md +79 -0
- package/src/bin.ts +3 -0
- package/src/cli.ts +149 -0
- package/src/commands/addresses.ts +319 -0
- package/src/commands/context.ts +13 -0
- package/src/commands/deploy.ts +188 -0
- package/src/commands/init.ts +81 -0
- package/src/commands/mail.ts +203 -0
- package/src/core/admin.ts +38 -0
- package/src/core/api.ts +101 -0
- package/src/core/args.ts +57 -0
- package/src/core/config.ts +84 -0
- package/src/core/credentials.ts +19 -0
- package/src/core/errors.ts +19 -0
- package/src/core/extract.ts +173 -0
- package/src/core/files.ts +40 -0
- package/src/core/help.ts +35 -0
- package/src/core/mail.ts +80 -0
- package/src/core/names.ts +77 -0
- package/src/core/paths.ts +43 -0
- package/src/core/prompt.ts +16 -0
- package/src/core/registry.ts +28 -0
- package/src/core/secrets.ts +62 -0
- package/src/core/time.ts +16 -0
- package/src/core/upstream.ts +96 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { stringFlag, UsageError } from '../core/args.ts'
|
|
2
|
+
import { ApiError } from '../core/api.ts'
|
|
3
|
+
import { addressJwt } from '../core/credentials.ts'
|
|
4
|
+
import { bestLink, localCode, serverCode } from '../core/extract.ts'
|
|
5
|
+
import { deleteMail, getMail, getRawMail, listMails, type Mail } from '../core/mail.ts'
|
|
6
|
+
import { parseDuration } from '../core/time.ts'
|
|
7
|
+
import { type Context, type Input, resolveAddress } from './context.ts'
|
|
8
|
+
|
|
9
|
+
export type MailView = Omit<Mail, 'metadata'>
|
|
10
|
+
|
|
11
|
+
function view(mail: Mail): MailView {
|
|
12
|
+
const { metadata: _, ...rest } = mail
|
|
13
|
+
return rest
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mailId(raw: string | undefined, usage: string): number {
|
|
17
|
+
const id = Number(raw)
|
|
18
|
+
if (!raw || !Number.isSafeInteger(id) || id <= 0) throw new UsageError(usage)
|
|
19
|
+
return id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mailText(mail: MailView): string {
|
|
23
|
+
const lines = [
|
|
24
|
+
`id ${mail.id}`,
|
|
25
|
+
`from ${mail.from}`,
|
|
26
|
+
`subject ${mail.subject}`,
|
|
27
|
+
`date ${mail.date}`,
|
|
28
|
+
]
|
|
29
|
+
if (mail.attachments.length > 0) {
|
|
30
|
+
lines.push(`files ${mail.attachments.map((file) => file.filename).join(', ')}`)
|
|
31
|
+
}
|
|
32
|
+
return `${lines.join('\n')}\n\n${mail.text}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const READ_USAGE = 'usage: buzon read <address> [--latest | --id <n> | --all] [--raw]'
|
|
36
|
+
|
|
37
|
+
export type ReadResult = { mails: MailView[] } | { raw: string }
|
|
38
|
+
|
|
39
|
+
export async function read(
|
|
40
|
+
{ positionals, flags }: Input,
|
|
41
|
+
{ config, api }: Context,
|
|
42
|
+
): Promise<ReadResult> {
|
|
43
|
+
const address = resolveAddress(positionals[0], config, READ_USAGE)
|
|
44
|
+
const idFlag = stringFlag(flags, 'id')
|
|
45
|
+
const all = flags.all === true
|
|
46
|
+
if ([idFlag !== undefined, all, flags.latest === true].filter(Boolean).length > 1) {
|
|
47
|
+
throw new UsageError(READ_USAGE)
|
|
48
|
+
}
|
|
49
|
+
if (all && flags.raw === true) throw new UsageError('--raw reads one mail, not --all')
|
|
50
|
+
const jwt = addressJwt(address)
|
|
51
|
+
|
|
52
|
+
let mails: Mail[]
|
|
53
|
+
if (idFlag !== undefined) {
|
|
54
|
+
const mail = await getMail(api, jwt, mailId(idFlag, READ_USAGE))
|
|
55
|
+
if (!mail) throw new Error(`no mail ${idFlag} for ${address}`)
|
|
56
|
+
mails = [mail]
|
|
57
|
+
} else {
|
|
58
|
+
mails = await listMails(api, jwt, all ? Number.POSITIVE_INFINITY : 1)
|
|
59
|
+
if (!all && mails.length === 0) throw new Error(`no mail for ${address}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (flags.raw === true) {
|
|
63
|
+
const raw = await getRawMail(api, jwt, mails[0]!.id)
|
|
64
|
+
if (raw === undefined) throw new Error(`no raw message for mail ${mails[0]!.id}`)
|
|
65
|
+
return { raw }
|
|
66
|
+
}
|
|
67
|
+
return { mails: mails.map(view) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readText(result: ReadResult): string {
|
|
71
|
+
if ('raw' in result) return result.raw
|
|
72
|
+
return result.mails.map(mailText).join('\n\n----\n\n') || 'no mail'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type Clock = { now(): number; sleep(ms: number): Promise<void> }
|
|
76
|
+
|
|
77
|
+
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) }
|
|
78
|
+
|
|
79
|
+
/** Upstream's polling etiquette: start at 3 s, back off to 10 s. */
|
|
80
|
+
const FIRST_POLL = 3000
|
|
81
|
+
const LAST_POLL = 10_000
|
|
82
|
+
const BACKOFF = 1.5
|
|
83
|
+
/** Mails looked at per poll, newest first. */
|
|
84
|
+
const WINDOW = 20
|
|
85
|
+
|
|
86
|
+
const WAIT_USAGE =
|
|
87
|
+
'usage: buzon wait <address> [--code | --link] [--from <regex>] [--subject <regex>] [--timeout 300] [--since now|<duration>|<date>]'
|
|
88
|
+
|
|
89
|
+
export class WaitTimeout extends Error {}
|
|
90
|
+
|
|
91
|
+
export type WaitResult = { id: number; code: string } | { id: number; link: string } | MailView
|
|
92
|
+
|
|
93
|
+
function regexFlag(flags: Input['flags'], name: string): RegExp | undefined {
|
|
94
|
+
const raw = stringFlag(flags, name)
|
|
95
|
+
if (raw === undefined) return undefined
|
|
96
|
+
try {
|
|
97
|
+
return new RegExp(raw, 'i')
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new UsageError(`--${name} is not a valid regular expression: ${(error as Error).message}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `now` waits for mail after the first poll, by id, so a skewed local clock cannot matter. */
|
|
104
|
+
function sinceFlag(raw: string | undefined, now: number): number | 'now' {
|
|
105
|
+
if (raw === undefined || raw === 'now') return 'now'
|
|
106
|
+
if (/^\d+(\.\d+)?[smhd]?$/.test(raw)) return now - parseDuration(raw, 'since')
|
|
107
|
+
const date = Date.parse(raw)
|
|
108
|
+
if (Number.isNaN(date))
|
|
109
|
+
throw new UsageError('--since takes now, a duration such as 10m, or a date')
|
|
110
|
+
return date
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Polls until a new mail passes the filters and yields what was asked for. A mail that
|
|
115
|
+
* passes the filters but has no code yet is looked at again on the next poll, since the
|
|
116
|
+
* instance saves its own code extraction just after the mail lands.
|
|
117
|
+
*/
|
|
118
|
+
export async function wait(
|
|
119
|
+
{ positionals, flags }: Input,
|
|
120
|
+
{ config, api }: Context,
|
|
121
|
+
clock: Clock = realClock,
|
|
122
|
+
): Promise<WaitResult> {
|
|
123
|
+
const address = resolveAddress(positionals[0], config, WAIT_USAGE)
|
|
124
|
+
const code = flags.code === true
|
|
125
|
+
const link = flags.link === true
|
|
126
|
+
if (code && link) throw new UsageError('--code and --link are exclusive')
|
|
127
|
+
const from = regexFlag(flags, 'from')
|
|
128
|
+
const subject = regexFlag(flags, 'subject')
|
|
129
|
+
const timeoutFlag = stringFlag(flags, 'timeout')
|
|
130
|
+
const timeout = timeoutFlag === undefined ? 300_000 : parseDuration(timeoutFlag, 'timeout')
|
|
131
|
+
const since = sinceFlag(stringFlag(flags, 'since'), clock.now())
|
|
132
|
+
const jwt = addressJwt(address)
|
|
133
|
+
|
|
134
|
+
const deadline = clock.now() + timeout
|
|
135
|
+
/** Whole seconds, the resolution of the instance's timestamps. */
|
|
136
|
+
const cutoff = since === 'now' ? undefined : Math.floor(since / 1000) * 1000
|
|
137
|
+
let baseline: number | undefined
|
|
138
|
+
let interval = FIRST_POLL
|
|
139
|
+
|
|
140
|
+
const match = (mail: Mail): WaitResult | undefined => {
|
|
141
|
+
const fresh = cutoff === undefined ? mail.id > baseline! : Date.parse(mail.date) >= cutoff
|
|
142
|
+
if (!fresh) return undefined
|
|
143
|
+
if (from && !from.test(mail.from)) return undefined
|
|
144
|
+
if (subject && !subject.test(mail.subject)) return undefined
|
|
145
|
+
if (code) {
|
|
146
|
+
const found = serverCode(mail.metadata) ?? localCode(mail.subject, mail.text)
|
|
147
|
+
return found ? { id: mail.id, code: found } : undefined
|
|
148
|
+
}
|
|
149
|
+
if (link) {
|
|
150
|
+
const found = bestLink(mail)
|
|
151
|
+
return found ? { id: mail.id, link: found } : undefined
|
|
152
|
+
}
|
|
153
|
+
return view(mail)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (;;) {
|
|
157
|
+
let pause = interval
|
|
158
|
+
try {
|
|
159
|
+
const mails = await listMails(api, jwt, WINDOW)
|
|
160
|
+
if (cutoff === undefined && baseline === undefined) {
|
|
161
|
+
baseline = Math.max(0, ...mails.map((mail) => mail.id))
|
|
162
|
+
} else {
|
|
163
|
+
for (const mail of mails) {
|
|
164
|
+
const result = match(mail)
|
|
165
|
+
if (result) return result
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
interval = Math.min(interval * BACKOFF, LAST_POLL)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!(error instanceof ApiError && error.status === 429)) throw error
|
|
171
|
+
pause = Math.max(pause, (error.retryAfter ?? 0) * 1000)
|
|
172
|
+
}
|
|
173
|
+
const remaining = deadline - clock.now()
|
|
174
|
+
if (remaining <= 0) {
|
|
175
|
+
throw new WaitTimeout(`no matching mail for ${address} within ${timeout / 1000}s`)
|
|
176
|
+
}
|
|
177
|
+
await clock.sleep(Math.min(pause, remaining))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function waitText(result: WaitResult): string {
|
|
182
|
+
if ('code' in result) return result.code
|
|
183
|
+
if ('link' in result) return result.link
|
|
184
|
+
return mailText(result)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DELETE_MAIL_USAGE = 'usage: buzon delete-mail <address> <id>'
|
|
188
|
+
|
|
189
|
+
/** The instance answers success for an id it does not hold, so the mail is looked up first. */
|
|
190
|
+
export async function removeMail(
|
|
191
|
+
{ positionals }: Input,
|
|
192
|
+
{ config, api }: Context,
|
|
193
|
+
): Promise<{ address: string; id: number }> {
|
|
194
|
+
const address = resolveAddress(positionals[0], config, DELETE_MAIL_USAGE)
|
|
195
|
+
const id = mailId(positionals[1], DELETE_MAIL_USAGE)
|
|
196
|
+
const jwt = addressJwt(address)
|
|
197
|
+
if (!(await getMail(api, jwt, id))) throw new Error(`no mail ${id} for ${address}`)
|
|
198
|
+
if (!(await deleteMail(api, jwt, id))) throw new Error(`the instance did not delete mail ${id}`)
|
|
199
|
+
return { address, id }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const removeMailText = (result: { address: string; id: number }) =>
|
|
203
|
+
`deleted mail ${result.id} of ${result.address}`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Api } from './api.ts'
|
|
2
|
+
|
|
3
|
+
/** A row of `GET /admin/address`. `name` is the full address. */
|
|
4
|
+
export type ServerAddress = { id: number; name: string; created_at: string }
|
|
5
|
+
|
|
6
|
+
type Page = { results: ServerAddress[] }
|
|
7
|
+
|
|
8
|
+
/** The largest page the instance serves. */
|
|
9
|
+
const LIMIT = 100
|
|
10
|
+
|
|
11
|
+
async function pages(api: Api, query?: string): Promise<ServerAddress[]> {
|
|
12
|
+
const out: ServerAddress[] = []
|
|
13
|
+
for (let offset = 0; ; offset += LIMIT) {
|
|
14
|
+
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) })
|
|
15
|
+
if (query) params.set('query', query)
|
|
16
|
+
const { results } = await api.request<Page>(`/admin/address?${params.toString()}`)
|
|
17
|
+
out.push(...results)
|
|
18
|
+
if (results.length < LIMIT) return out
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function listServerAddresses(api: Api): Promise<ServerAddress[]> {
|
|
23
|
+
return pages(api)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The search is a substring match, so the exact name is picked out of it. */
|
|
27
|
+
export async function findServerAddress(
|
|
28
|
+
api: Api,
|
|
29
|
+
address: string,
|
|
30
|
+
): Promise<ServerAddress | undefined> {
|
|
31
|
+
return (await pages(api, address)).find((row) => row.name === address)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A fresh address JWT, signed by the instance for any address id. */
|
|
35
|
+
export async function mintJwt(api: Api, id: number): Promise<string> {
|
|
36
|
+
const { jwt } = await api.request<{ jwt: string }>(`/admin/show_password/${id}`)
|
|
37
|
+
return jwt
|
|
38
|
+
}
|
package/src/core/api.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Config } from './config.ts'
|
|
2
|
+
import { secret as keyringSecret, type SecretKey } from './secrets.ts'
|
|
3
|
+
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
readonly status: number,
|
|
7
|
+
readonly path: string,
|
|
8
|
+
message: string,
|
|
9
|
+
/** Upstream error code, when the body was `{ code, message }` JSON instead of text. */
|
|
10
|
+
readonly code?: string,
|
|
11
|
+
/** Seconds from a `Retry-After` header, on a 429. */
|
|
12
|
+
readonly retryAfter?: number,
|
|
13
|
+
) {
|
|
14
|
+
super(`${path}: ${status} ${message}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type RequestOptions = {
|
|
19
|
+
method?: string
|
|
20
|
+
body?: unknown
|
|
21
|
+
/** Address JWT, sent as a bearer token. `/api/*` routes need one, except a few public ones. */
|
|
22
|
+
jwt?: string
|
|
23
|
+
/** Leave the site password out, for a probe that must not depend on it. */
|
|
24
|
+
anonymous?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Api = {
|
|
28
|
+
request<T>(path: string, options?: RequestOptions): Promise<T>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upstream answers errors as plain text, or as `{ code, message }` JSON for auth failures.
|
|
33
|
+
* Both become an `ApiError` with the status kept.
|
|
34
|
+
*/
|
|
35
|
+
async function failure(response: Response, path: string): Promise<ApiError> {
|
|
36
|
+
const text = (await response.text()).trim()
|
|
37
|
+
const retry = Number(response.headers.get('retry-after') ?? Number.NaN)
|
|
38
|
+
const retryAfter = Number.isFinite(retry) && retry >= 0 ? retry : undefined
|
|
39
|
+
try {
|
|
40
|
+
const body = JSON.parse(text) as { code?: unknown; message?: unknown }
|
|
41
|
+
if (typeof body.message === 'string') {
|
|
42
|
+
return new ApiError(
|
|
43
|
+
response.status,
|
|
44
|
+
path,
|
|
45
|
+
body.message,
|
|
46
|
+
typeof body.code === 'string' ? body.code : undefined,
|
|
47
|
+
retryAfter,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
return new ApiError(response.status, path, text || response.statusText, undefined, retryAfter)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Secrets are read on first use, so a command that never calls `/admin/*` never needs one. */
|
|
55
|
+
export function createApi(
|
|
56
|
+
config: Pick<Config, 'url'>,
|
|
57
|
+
secret: (key: SecretKey) => string = keyringSecret,
|
|
58
|
+
fetcher: typeof fetch = fetch,
|
|
59
|
+
): Api {
|
|
60
|
+
const cached = new Map<SecretKey, string>()
|
|
61
|
+
const get = (key: SecretKey) => {
|
|
62
|
+
let value = cached.get(key)
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
value = secret(key)
|
|
65
|
+
cached.set(key, value)
|
|
66
|
+
}
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
72
|
+
const headers = new Headers({ accept: 'application/json' })
|
|
73
|
+
if (!options.anonymous) headers.set('x-custom-auth', get('site-password'))
|
|
74
|
+
if (options.jwt) headers.set('authorization', `Bearer ${options.jwt}`)
|
|
75
|
+
if (path.startsWith('/admin/')) headers.set('x-admin-auth', get('admin-password'))
|
|
76
|
+
let body: string | undefined
|
|
77
|
+
if (options.body !== undefined) {
|
|
78
|
+
headers.set('content-type', 'application/json')
|
|
79
|
+
body = JSON.stringify(options.body)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let response: Response
|
|
83
|
+
try {
|
|
84
|
+
response = await fetcher(`${config.url}${path}`, {
|
|
85
|
+
method: options.method ?? (body === undefined ? 'GET' : 'POST'),
|
|
86
|
+
headers,
|
|
87
|
+
body,
|
|
88
|
+
})
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(`${config.url}${path}: request failed`, { cause: error })
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) throw await failure(response, path)
|
|
93
|
+
|
|
94
|
+
const text = await response.text()
|
|
95
|
+
if (!response.headers.get('content-type')?.includes('application/json')) {
|
|
96
|
+
throw new ApiError(response.status, path, 'expected JSON, got something else')
|
|
97
|
+
}
|
|
98
|
+
return JSON.parse(text) as T
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/core/args.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class UsageError extends Error {}
|
|
2
|
+
|
|
3
|
+
export type Parsed = {
|
|
4
|
+
command: string
|
|
5
|
+
positionals: string[]
|
|
6
|
+
flags: Record<string, string | boolean>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const VALUE_FLAGS = new Set([
|
|
10
|
+
'url',
|
|
11
|
+
'domain',
|
|
12
|
+
'site',
|
|
13
|
+
'note',
|
|
14
|
+
'tier',
|
|
15
|
+
'id',
|
|
16
|
+
'from',
|
|
17
|
+
'subject',
|
|
18
|
+
'timeout',
|
|
19
|
+
'since',
|
|
20
|
+
'tag',
|
|
21
|
+
'to',
|
|
22
|
+
'out',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export function parseArgv(argv: string[]): Parsed {
|
|
26
|
+
const positionals: string[] = []
|
|
27
|
+
const flags: Record<string, string | boolean> = {}
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const arg = argv[i]!
|
|
31
|
+
if (!arg.startsWith('-')) {
|
|
32
|
+
positionals.push(arg)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const flag = arg.replace(/^--?/, '')
|
|
36
|
+
const eq = flag.indexOf('=')
|
|
37
|
+
const name = eq < 0 ? flag : flag.slice(0, eq)
|
|
38
|
+
const inline = eq < 0 ? undefined : flag.slice(eq + 1)
|
|
39
|
+
if (VALUE_FLAGS.has(name)) {
|
|
40
|
+
const value = inline ?? argv[++i]
|
|
41
|
+
if (value === undefined) throw new UsageError(`--${name} needs a value`)
|
|
42
|
+
flags[name] = value
|
|
43
|
+
} else {
|
|
44
|
+
flags[name] = inline ?? true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { command: positionals.shift() ?? '', positionals, flags }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A flag that must carry a value, or nothing: `--url` alone is a usage error, not `true`. */
|
|
52
|
+
export function stringFlag(flags: Parsed['flags'], name: string): string | undefined {
|
|
53
|
+
const value = flags[name]
|
|
54
|
+
if (value === undefined) return undefined
|
|
55
|
+
if (typeof value !== 'string' || value === '') throw new UsageError(`--${name} needs a value`)
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { paths } from './paths.ts'
|
|
3
|
+
import { writeAtomic } from './files.ts'
|
|
4
|
+
|
|
5
|
+
export type Config = {
|
|
6
|
+
/** Origin of the instance, without a trailing slash: `https://mail.example.com`. */
|
|
7
|
+
url: string
|
|
8
|
+
/** Domain the addresses are created on: `example.com`. */
|
|
9
|
+
domain: string
|
|
10
|
+
/** Upstream release the instance runs, as `buzon deploy` last shipped it: `v1.13.0`. */
|
|
11
|
+
tag?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ConfigMissingError extends Error {
|
|
15
|
+
constructor(file: string) {
|
|
16
|
+
super(`no config at ${file}. Run \`buzon init\` first.`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeUrl(raw: string): string {
|
|
21
|
+
let url: URL
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(raw)
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`"${raw}" is not a URL`)
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
28
|
+
throw new Error(`"${raw}" is not an http(s) URL`)
|
|
29
|
+
}
|
|
30
|
+
return url.origin
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeDomain(raw: string): string {
|
|
34
|
+
const domain = raw.trim().toLowerCase()
|
|
35
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) throw new Error(`"${raw}" is not a domain`)
|
|
36
|
+
return domain
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeTag(raw: string): string {
|
|
40
|
+
const tag = raw.trim()
|
|
41
|
+
if (!/^v\d+\.\d+\.\d+$/.test(tag))
|
|
42
|
+
throw new Error(`"${raw}" is not a release tag such as v1.13.0`)
|
|
43
|
+
return tag
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseConfig(text: string, file = paths.config): Config {
|
|
47
|
+
let raw: Record<string, unknown>
|
|
48
|
+
try {
|
|
49
|
+
raw = Bun.TOML.parse(text) as Record<string, unknown>
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(`${file} is not valid TOML`, { cause: error })
|
|
52
|
+
}
|
|
53
|
+
const { url, domain, tag } = raw
|
|
54
|
+
if (typeof url !== 'string') throw new Error(`${file}: \`url\` is missing`)
|
|
55
|
+
if (typeof domain !== 'string') throw new Error(`${file}: \`domain\` is missing`)
|
|
56
|
+
const config: Config = { url: normalizeUrl(url), domain: normalizeDomain(domain) }
|
|
57
|
+
if (tag !== undefined) {
|
|
58
|
+
if (typeof tag !== 'string') throw new Error(`${file}: \`tag\` must be a string`)
|
|
59
|
+
config.tag = normalizeTag(tag)
|
|
60
|
+
}
|
|
61
|
+
return config
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readConfig(file = paths.config): Config {
|
|
65
|
+
let text: string
|
|
66
|
+
try {
|
|
67
|
+
text = readFileSync(file, 'utf8')
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ConfigMissingError(file)
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
return parseConfig(text, file)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** JSON string escapes are valid TOML basic-string escapes, so `JSON.stringify` quotes safely. */
|
|
76
|
+
export function serializeConfig(config: Config): string {
|
|
77
|
+
const lines = [`url = ${JSON.stringify(config.url)}`, `domain = ${JSON.stringify(config.domain)}`]
|
|
78
|
+
if (config.tag) lines.push(`tag = ${JSON.stringify(config.tag)}`)
|
|
79
|
+
return `${lines.join('\n')}\n`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeConfig(config: Config, file = paths.config): void {
|
|
83
|
+
writeAtomic(file, serializeConfig(config))
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { paths } from './paths.ts'
|
|
2
|
+
import { readJson, writeJson } from './files.ts'
|
|
3
|
+
|
|
4
|
+
/** Address JWTs by address. Owner-only, and outside the tracked data directory. */
|
|
5
|
+
export type Credentials = Record<string, string>
|
|
6
|
+
|
|
7
|
+
export function readCredentials(file = paths.credentials): Credentials {
|
|
8
|
+
return (readJson(file) as Credentials | undefined) ?? {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function writeCredentials(credentials: Credentials, file = paths.credentials): void {
|
|
12
|
+
writeJson(file, credentials, 0o600)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function addressJwt(address: string, file = paths.credentials): string {
|
|
16
|
+
const jwt = readCredentials(file)[address]
|
|
17
|
+
if (!jwt) throw new Error(`no credential for ${address}: run \`buzon reissue ${address}\``)
|
|
18
|
+
return jwt
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** How many causes deep an error is followed. A wrapper and what it wraps is the usual case. */
|
|
2
|
+
const DEPTH = 3
|
|
3
|
+
|
|
4
|
+
function text(error: unknown): string {
|
|
5
|
+
if (!(error instanceof Error)) return String(error)
|
|
6
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
7
|
+
return code && !error.message.includes(code) ? `${error.message} (${code})` : error.message
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The message followed by its `cause` chain, so "fetch failed" can say DNS from TLS. */
|
|
11
|
+
export function causeChain(error: unknown): string[] {
|
|
12
|
+
const out = [text(error)]
|
|
13
|
+
let at = error instanceof Error ? error.cause : undefined
|
|
14
|
+
while (at !== undefined && out.length < DEPTH) {
|
|
15
|
+
out.push(text(at))
|
|
16
|
+
at = at instanceof Error ? at.cause : undefined
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|