ardent-cli-beta 0.0.18 → 0.0.19
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 +92 -28
- package/dist/api/client.d.ts +2 -0
- package/dist/api/client.js +13 -2
- package/dist/auth/credentials.d.ts +1 -0
- package/dist/auth/credentials.js +44 -4
- package/dist/branches/client.d.ts +3 -1
- package/dist/branches/client.js +41 -4
- package/dist/branches/connection.d.ts +21 -0
- package/dist/branches/connection.js +143 -0
- package/dist/branches/credentials.d.ts +18 -0
- package/dist/branches/credentials.js +188 -0
- package/dist/branches/drivers.d.ts +6 -0
- package/dist/branches/drivers.js +77 -0
- package/dist/branches/env.d.ts +1 -0
- package/dist/branches/env.js +55 -0
- package/dist/branches/poll.d.ts +3 -0
- package/dist/branches/poll.js +40 -0
- package/dist/branches/psql.d.ts +14 -0
- package/dist/branches/psql.js +159 -0
- package/dist/branches/recovery.d.ts +19 -0
- package/dist/branches/recovery.js +211 -0
- package/dist/commands/branch/connect.d.ts +12 -0
- package/dist/commands/branch/connect.js +87 -0
- package/dist/commands/branch/create.d.ts +9 -1
- package/dist/commands/branch/create.js +73 -15
- package/dist/commands/branch/recover.d.ts +11 -0
- package/dist/commands/branch/recover.js +37 -0
- package/dist/commands/branch/suspend.d.ts +11 -0
- package/dist/commands/branch/suspend.js +37 -0
- package/dist/commands/branch/switch.js +1 -1
- package/dist/commands/branch/wake.d.ts +11 -0
- package/dist/commands/branch/wake.js +37 -0
- package/dist/commands/connector/create.d.ts +2 -0
- package/dist/commands/connector/create.js +20 -2
- package/dist/commands/connector/delete.d.ts +10 -0
- package/dist/commands/connector/delete.js +62 -0
- package/dist/commands/connector/status.d.ts +10 -0
- package/dist/commands/connector/status.js +28 -0
- package/dist/commands/connector/switch.d.ts +12 -0
- package/dist/commands/connector/switch.js +45 -0
- package/dist/commands/login.js +7 -1
- package/dist/commands/project/switch.js +1 -1
- package/dist/connectors/client.d.ts +8 -4
- package/dist/connectors/client.js +62 -13
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +7 -0
- package/oclif.manifest.json +319 -2
- package/openapi.json +36 -7
- package/package.json +5 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { APIError, API_URL, request } from '../api/client.js';
|
|
2
|
+
import { DRIVERS } from './drivers.js';
|
|
3
|
+
import { pauseFor, pollPauser } from './poll.js';
|
|
4
|
+
import { terminalText } from '../terminal.js';
|
|
5
|
+
export class CredentialIssueRefused extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// The issuance refusals that come before any issuance is recorded: a request
|
|
13
|
+
// refused with one of these left nothing to revoke.
|
|
14
|
+
const sourceRoleIssueRefusals = new Set(['invalid_source_role', 'source_role_unsupported', 'source_role_held', 'source_role_revocation_pending']);
|
|
15
|
+
// sourceRolePattern is the name the API accepts for a source role: an
|
|
16
|
+
// unquoted lower-case PostgreSQL identifier that begins with neither pg_ nor
|
|
17
|
+
// ardent. The CLI checks it before it records or sends anything, so a mistyped
|
|
18
|
+
// name never reaches a recovery record.
|
|
19
|
+
const sourceRolePattern = /^(?!pg_|ardent)[a-z_][a-z0-9_]{0,62}$/;
|
|
20
|
+
export function validSourceRole(role) {
|
|
21
|
+
return sourceRolePattern.test(role);
|
|
22
|
+
}
|
|
23
|
+
export function refusedBeforeIssuance(error) {
|
|
24
|
+
return error instanceof CredentialIssueRefused && error.code !== undefined && sourceRoleIssueRefusals.has(error.code);
|
|
25
|
+
}
|
|
26
|
+
function credentialPath(issuanceID) {
|
|
27
|
+
return `/branch-credentials/${encodeURIComponent(issuanceID)}`;
|
|
28
|
+
}
|
|
29
|
+
function safeRequest(token, path, init) {
|
|
30
|
+
return request(token, path, init).catch((error) => {
|
|
31
|
+
if (error instanceof APIError) {
|
|
32
|
+
// A coded refusal carries the API's own explanation, such as why a
|
|
33
|
+
// source role cannot be issued; an uncoded one is reported by status.
|
|
34
|
+
const detail = error.code ? ` ${terminalText(error.message)} (${terminalText(error.code)})` : '';
|
|
35
|
+
const message = `Branch credential request failed (HTTP ${error.status}).${detail}`;
|
|
36
|
+
if (error.status >= 400 && error.status < 500)
|
|
37
|
+
throw new CredentialIssueRefused(message, error.code);
|
|
38
|
+
throw new Error(message);
|
|
39
|
+
}
|
|
40
|
+
throw new Error('Branch credential request failed. Check the API connection and try again.');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// sourceRole, when given, asks for a fresh branch-only password on that
|
|
44
|
+
// source role instead of an issued public read/write role. A replay of the same
|
|
45
|
+
// request must name the same role.
|
|
46
|
+
export async function issueBranchCredential(token, branchID, requestID, sourceRole) {
|
|
47
|
+
const body = sourceRole === undefined
|
|
48
|
+
? { request_id: requestID, access_profile: 'public_read_write_v1' }
|
|
49
|
+
: { request_id: requestID, source_role: sourceRole };
|
|
50
|
+
const result = await safeRequest(token, `/branches/${encodeURIComponent(branchID)}/credentials`, {
|
|
51
|
+
method: 'POST', body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
if (!result || typeof result.issuance_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result.issuance_id) || result.status !== 'pending') {
|
|
54
|
+
throw new Error('Ardent API returned an invalid branch credential issuance.');
|
|
55
|
+
}
|
|
56
|
+
return result.issuance_id;
|
|
57
|
+
}
|
|
58
|
+
// stop, when given, ends the wait with its abort reason: a caller that learns
|
|
59
|
+
// the branch failed has no delivery left to wait for. served, when given,
|
|
60
|
+
// settles once the branch serves; the ten-minute deadline runs from then,
|
|
61
|
+
// since the control plane delivers nothing before it, and until then the wait
|
|
62
|
+
// is bounded by whatever bounds served.
|
|
63
|
+
export async function waitForBranchCredential(token, issuanceID, stop, served) {
|
|
64
|
+
const bound = 10 * 60 * 1000;
|
|
65
|
+
let deadline = served ? Number.POSITIVE_INFINITY : Date.now() + bound;
|
|
66
|
+
served?.then(() => { deadline = Date.now() + bound; }, () => { });
|
|
67
|
+
// A 202 is re-read on the poll cadence; a 5xx or a lost response is retried
|
|
68
|
+
// two seconds later. A server failure the API reports as 202 is
|
|
69
|
+
// indistinguishable from pending here and gets the poll cadence;
|
|
70
|
+
// docs/DECISIONS.md section 16 says when: "the API answers 202 when it
|
|
71
|
+
// cannot load a delivered credential's connection details". Both pauses
|
|
72
|
+
// end with stop's reason as soon as it aborts.
|
|
73
|
+
const pollPause = pollPauser(stop);
|
|
74
|
+
const retryPause = () => pauseFor(2000, stop);
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
stop?.throwIfAborted();
|
|
77
|
+
// The API has JSON and plain-text pending bodies. HTTP 202 is the only
|
|
78
|
+
// pending signal; only HTTP 200 delivers the one-time URI.
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetch(new URL(credentialPath(issuanceID), `${API_URL}/`), {
|
|
82
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
83
|
+
signal: stop ? AbortSignal.any([stop, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
84
|
+
redirect: 'error',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
await retryPause();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (response.status === 202) {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
await pollPause();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (response.status >= 500) {
|
|
97
|
+
await response.body?.cancel();
|
|
98
|
+
await retryPause();
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (response.status === 409) {
|
|
102
|
+
// The branch's node refused the source role by name; the issuance is
|
|
103
|
+
// still revoked by the caller.
|
|
104
|
+
let refusal;
|
|
105
|
+
try {
|
|
106
|
+
refusal = await response.json();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
refusal = undefined;
|
|
110
|
+
}
|
|
111
|
+
const fields = typeof refusal === 'object' && refusal !== null ? refusal : {};
|
|
112
|
+
if (typeof fields.error === 'string' && typeof fields.message === 'string') {
|
|
113
|
+
throw new Error(`${terminalText(fields.message)} (${terminalText(fields.error)})`);
|
|
114
|
+
}
|
|
115
|
+
throw new Error('Branch credential delivery was refused (HTTP 409).');
|
|
116
|
+
}
|
|
117
|
+
if (response.status !== 200) {
|
|
118
|
+
await response.body?.cancel();
|
|
119
|
+
throw new Error(`Branch credential delivery failed (HTTP ${response.status}).`);
|
|
120
|
+
}
|
|
121
|
+
let result;
|
|
122
|
+
try {
|
|
123
|
+
result = await response.json();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error('Ardent API returned an invalid branch credential delivery.');
|
|
127
|
+
}
|
|
128
|
+
if (typeof result !== 'object' || result === null)
|
|
129
|
+
throw new Error('Ardent API returned an invalid branch credential delivery.');
|
|
130
|
+
const delivered = result;
|
|
131
|
+
const strings = delivered.connection_strings;
|
|
132
|
+
const stringsValid = strings === undefined || (typeof strings === 'object' && strings !== null && !Array.isArray(strings) &&
|
|
133
|
+
DRIVERS.every((driver) => typeof strings[driver] === 'string' && strings[driver] !== '') &&
|
|
134
|
+
strings.libpq === delivered.uri);
|
|
135
|
+
if (delivered.issuance_id !== issuanceID || typeof delivered.delivery_id !== 'string' || !delivered.delivery_id ||
|
|
136
|
+
typeof delivered.uri !== 'string' || !delivered.uri || !stringsValid)
|
|
137
|
+
throw new Error('Ardent API returned an invalid branch credential delivery.');
|
|
138
|
+
return delivered;
|
|
139
|
+
}
|
|
140
|
+
throw new Error('Timed out waiting for a branch credential. Check branch status before trying again.');
|
|
141
|
+
}
|
|
142
|
+
export async function acknowledgeBranchCredential(token, issuanceID, deliveryID) {
|
|
143
|
+
const deadline = Date.now() + 30_000;
|
|
144
|
+
while (true) {
|
|
145
|
+
try {
|
|
146
|
+
const result = await request(token, `${credentialPath(issuanceID)}/ack`, {
|
|
147
|
+
method: 'POST', body: JSON.stringify({ delivery_id: deliveryID }),
|
|
148
|
+
});
|
|
149
|
+
if (result !== undefined)
|
|
150
|
+
throw new Error('Ardent API returned an invalid branch credential acknowledgement.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error instanceof APIError && error.status < 500) {
|
|
155
|
+
throw new Error(`Branch credential acknowledgement was refused (HTTP ${error.status}).`);
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() >= deadline)
|
|
158
|
+
throw new Error('Branch credential acknowledgement could not be confirmed.');
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function revokeBranchCredential(token, issuanceID) {
|
|
164
|
+
const deadline = Date.now() + 30_000;
|
|
165
|
+
while (true) {
|
|
166
|
+
let response;
|
|
167
|
+
try {
|
|
168
|
+
response = await fetch(new URL(credentialPath(issuanceID), `${API_URL}/`), {
|
|
169
|
+
method: 'DELETE',
|
|
170
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
171
|
+
signal: AbortSignal.timeout(10_000),
|
|
172
|
+
redirect: 'error',
|
|
173
|
+
});
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// A lost response leaves the outcome unknown; the same issuance may be
|
|
178
|
+
// revoked again without creating another credential.
|
|
179
|
+
}
|
|
180
|
+
if (response?.status === 202)
|
|
181
|
+
return;
|
|
182
|
+
if (response && response.status < 500)
|
|
183
|
+
throw new Error(`Credential revocation was refused (HTTP ${response.status}).`);
|
|
184
|
+
if (Date.now() >= deadline)
|
|
185
|
+
throw new Error('Credential revocation could not be confirmed.');
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DRIVERS: readonly ["libpq", "node", "jdbc", "prisma"];
|
|
2
|
+
export type Driver = typeof DRIVERS[number];
|
|
3
|
+
export type ConnectionStrings = Record<Driver, string>;
|
|
4
|
+
export declare const CLIENT_REQUIREMENTS: Record<Driver, readonly string[]>;
|
|
5
|
+
export declare function validDriver(value: string): value is Driver;
|
|
6
|
+
export declare function connectionString(driver: Driver, uri: string, strings: ConnectionStrings | undefined): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A delivered credential names one connection string per driver family,
|
|
2
|
+
// because no single PostgreSQL URI verifies the branch's certificate on every
|
|
3
|
+
// driver: libpq asks for the platform's trusted roots with
|
|
4
|
+
// sslrootcert=system, which node-postgres reads as a file, postgres.js sends to
|
|
5
|
+
// the server, and pgjdbc cannot parse; and Prisma's engine verifies nothing
|
|
6
|
+
// unless sslaccept=strict. Why the CLI prints only the form the user names is
|
|
7
|
+
// docs/DECISIONS.md section 15, "Rejected: printing every form at once".
|
|
8
|
+
export const DRIVERS = ['libpq', 'node', 'jdbc', 'prisma'];
|
|
9
|
+
// Each form must still verify the server, so the CLI checks the TLS settings
|
|
10
|
+
// the API sent before it prints one. A form that would connect without
|
|
11
|
+
// verification is refused rather than passed on.
|
|
12
|
+
const verified = {
|
|
13
|
+
libpq: (value) => postgresQuery(value, { sslmode: 'verify-full', sslrootcert: 'system' }),
|
|
14
|
+
node: (value) => postgresQuery(value, { sslmode: 'verify-full' }),
|
|
15
|
+
prisma: (value) => postgresQuery(value, { sslmode: 'require', sslaccept: 'strict' }),
|
|
16
|
+
jdbc: (value) => {
|
|
17
|
+
if (!value.startsWith('jdbc:postgresql://'))
|
|
18
|
+
return false;
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(value.slice('jdbc:'.length));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const parameters = url.searchParams;
|
|
27
|
+
return Boolean(url.hostname && url.port) && !url.username && exactly(parameters, ['user', 'password', 'sslmode', 'sslfactory']) &&
|
|
28
|
+
Boolean(parameters.get('user') && parameters.get('password')) &&
|
|
29
|
+
parameters.get('sslmode') === 'verify-full' && parameters.get('sslfactory') === 'org.postgresql.ssl.DefaultJavaSSLFactory';
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
function postgresQuery(value, want) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return url.protocol === 'postgresql:' && Boolean(url.hostname && url.port && url.username && url.password) && !url.hash &&
|
|
41
|
+
exactly(url.searchParams, Object.keys(want)) && Object.entries(want).every(([key, setting]) => url.searchParams.get(key) === setting);
|
|
42
|
+
}
|
|
43
|
+
// exactly reports whether parameters holds each of keys once and nothing else,
|
|
44
|
+
// so a repeated key cannot stand in for a missing one.
|
|
45
|
+
function exactly(parameters, keys) {
|
|
46
|
+
const present = [...parameters.keys()];
|
|
47
|
+
return present.length === keys.length && keys.every((key) => parameters.getAll(key).length === 1);
|
|
48
|
+
}
|
|
49
|
+
// What a form needs in the client's own code or environment, which no
|
|
50
|
+
// connection string can carry. The libpq form asks for the platform's trusted
|
|
51
|
+
// roots, and several common clients cannot reach them unaided.
|
|
52
|
+
export const CLIENT_REQUIREMENTS = {
|
|
53
|
+
libpq: [
|
|
54
|
+
'libpq 16 or newer: libpq 14 and 15 cannot check the server against the system\'s trusted roots.',
|
|
55
|
+
'pgx 5.7 or newer. pgx is pure Go, so libpq does not help it; an older pgx connects with --driver node.',
|
|
56
|
+
'psycopg binary wheels (psycopg[binary], psycopg2-binary): set SSL_CERT_FILE to the system CA bundle, for example /etc/ssl/certs/ca-certificates.crt. The wheel\'s bundled OpenSSL does not read the system trust store.',
|
|
57
|
+
'asyncpg: pass ssl=ssl.create_default_context() to asyncpg.connect. asyncpg reads sslrootcert only as a file path.',
|
|
58
|
+
],
|
|
59
|
+
node: [],
|
|
60
|
+
jdbc: [],
|
|
61
|
+
prisma: [],
|
|
62
|
+
};
|
|
63
|
+
export function validDriver(value) {
|
|
64
|
+
return DRIVERS.includes(value);
|
|
65
|
+
}
|
|
66
|
+
// connectionString returns the delivered form for driver, refusing an absent
|
|
67
|
+
// or unverifying one. An API that predates per-driver strings delivered only
|
|
68
|
+
// the libpq form, as uri.
|
|
69
|
+
export function connectionString(driver, uri, strings) {
|
|
70
|
+
const value = strings ? strings[driver] : (driver === 'libpq' ? uri : undefined);
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
throw new Error(`This Ardent API delivers no ${driver} connection string yet; only --driver libpq is available.`);
|
|
73
|
+
}
|
|
74
|
+
if (!verified[driver](value))
|
|
75
|
+
throw new Error(`Ardent API returned a ${driver} connection string that does not verify the server.`);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeBranchEnv(value: string, directory?: string): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseEnv } from 'node:util';
|
|
5
|
+
// Replace through a private sibling so a failed write cannot truncate .env.
|
|
6
|
+
// This does not coordinate with other programs editing the file concurrently.
|
|
7
|
+
export async function writeBranchEnv(value, directory = process.cwd()) {
|
|
8
|
+
const destination = join(directory, '.env');
|
|
9
|
+
const temporary = join(directory, `.env.${randomUUID()}.tmp`);
|
|
10
|
+
try {
|
|
11
|
+
let contents = '';
|
|
12
|
+
try {
|
|
13
|
+
const info = await lstat(destination);
|
|
14
|
+
if (!info.isFile() || info.nlink !== 1)
|
|
15
|
+
throw new Error('Unsafe destination');
|
|
16
|
+
contents = await readFile(destination, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT')
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
if (Object.hasOwn(parseEnv(contents), 'DATABASE_URL')) {
|
|
23
|
+
throw new Error('DATABASE_URL already exists in .env; remove it explicitly before retrying.');
|
|
24
|
+
}
|
|
25
|
+
const quote = ['\'', '"', '`'].find((candidate) => !value.includes(candidate));
|
|
26
|
+
if (!quote || /[\r\n\0]/.test(value))
|
|
27
|
+
throw new Error('Unsupported dotenv value');
|
|
28
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
29
|
+
try {
|
|
30
|
+
await file.writeFile(`${contents}${contents && !contents.endsWith('\n') ? '\n' : ''}DATABASE_URL=${quote}${value}${quote}\n`);
|
|
31
|
+
await file.sync();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await file.close();
|
|
35
|
+
}
|
|
36
|
+
await rename(temporary, destination);
|
|
37
|
+
const parent = await open(directory, 'r');
|
|
38
|
+
try {
|
|
39
|
+
await parent.sync();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await parent.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof Error && error.message.startsWith('DATABASE_URL already exists'))
|
|
47
|
+
throw error;
|
|
48
|
+
// File system errors can include caller-controlled paths; never include the
|
|
49
|
+
// credential or raw error in terminal output.
|
|
50
|
+
throw new Error('Could not write DATABASE_URL to .env. Check the file type, permissions and available space.');
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await unlink(temporary).catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The cadence for re-reading a branch result that is not ready yet: a workflow
|
|
2
|
+
// still running, or a credential still pending. The first re-read comes 100 ms
|
|
3
|
+
// after the first answer and the interval doubles to a one-second cap, so a
|
|
4
|
+
// short wait ends soon after the result is ready and a long one costs at most
|
|
5
|
+
// one read a second. docs/DECISIONS.md section 16, "A branch wait re-reads
|
|
6
|
+
// fast, then once a second", is why the CLI polls at all and why at this
|
|
7
|
+
// cadence. This is for "not ready" answers only; a failed
|
|
8
|
+
// request keeps its own retry spacing at each call site.
|
|
9
|
+
const FIRST_POLL_MS = 100;
|
|
10
|
+
const MAX_POLL_MS = 1000;
|
|
11
|
+
export function pollIntervalMs(attempt) {
|
|
12
|
+
if (!Number.isInteger(attempt) || attempt < 0)
|
|
13
|
+
throw new Error(`poll attempt must be a non-negative integer, got ${attempt}`);
|
|
14
|
+
return Math.min(FIRST_POLL_MS * 2 ** attempt, MAX_POLL_MS);
|
|
15
|
+
}
|
|
16
|
+
// pollPauser returns one wait's pause: each call sleeps the next interval.
|
|
17
|
+
// stop, when given, is passed to every pause (pauseFor).
|
|
18
|
+
export function pollPauser(stop) {
|
|
19
|
+
let attempt = 0;
|
|
20
|
+
return () => pauseFor(pollIntervalMs(attempt++), stop);
|
|
21
|
+
}
|
|
22
|
+
// pauseFor sleeps ms. When stop aborts, before or during the sleep, it rejects
|
|
23
|
+
// at once with stop's reason, so a wait whose caller gave up sends nothing more.
|
|
24
|
+
export function pauseFor(ms, stop) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (stop?.aborted) {
|
|
27
|
+
reject(stop.reason);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const abort = () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
reject(stop?.reason);
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
stop?.removeEventListener('abort', abort);
|
|
36
|
+
resolve();
|
|
37
|
+
}, ms);
|
|
38
|
+
stop?.addEventListener('abort', abort, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface ConnectionEnvironment {
|
|
2
|
+
PGHOST: string;
|
|
3
|
+
PGPORT: string;
|
|
4
|
+
PGDATABASE: string;
|
|
5
|
+
PGUSER: string;
|
|
6
|
+
PGPASSWORD: string;
|
|
7
|
+
PGSSLMODE: string;
|
|
8
|
+
PGSSLROOTCERT: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function connectionEnvironment(uri: string): ConnectionEnvironment;
|
|
11
|
+
export declare function libpqTooOld(found: string): string;
|
|
12
|
+
export declare function checkPsql(): Promise<void>;
|
|
13
|
+
export declare function runPsql(connection: ConnectionEnvironment): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
function terminalCommand(arguments_) {
|
|
3
|
+
if (process.platform === 'darwin')
|
|
4
|
+
return ['-q', '-e', '/dev/null', 'psql', ...arguments_];
|
|
5
|
+
if (process.platform === 'linux') {
|
|
6
|
+
// util-linux 2.39 requires -c; its later positional `-- command` form is
|
|
7
|
+
// unavailable on Ubuntu 24.04. Every argument here is a fixed CLI option.
|
|
8
|
+
const command = ['psql', ...arguments_].map((argument) => `'${argument.replaceAll("'", "'\\''")}'`).join(' ');
|
|
9
|
+
return ['-q', '-e', '-c', command, '/dev/null'];
|
|
10
|
+
}
|
|
11
|
+
throw new Error('Branch connect requires macOS or Linux with the system script utility.');
|
|
12
|
+
}
|
|
13
|
+
export function connectionEnvironment(uri) {
|
|
14
|
+
let url;
|
|
15
|
+
let database;
|
|
16
|
+
let user;
|
|
17
|
+
let password;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(uri);
|
|
20
|
+
database = decodeURIComponent(url.pathname.slice(1));
|
|
21
|
+
user = decodeURIComponent(url.username);
|
|
22
|
+
password = decodeURIComponent(url.password);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error('Ardent API returned an invalid branch connection.');
|
|
26
|
+
}
|
|
27
|
+
const parameters = [...url.searchParams.entries()];
|
|
28
|
+
const controls = /[\u0000-\u001f\u007f]/;
|
|
29
|
+
// A terminating environment's username is <role>+<api key>, which its node
|
|
30
|
+
// proxy splits; a passthrough environment's is the role alone, because no
|
|
31
|
+
// proxy is there to split it. Either way neither half may be empty.
|
|
32
|
+
const [role, ...apiKey] = user.split('+');
|
|
33
|
+
if (url.protocol !== 'postgresql:' || !url.hostname || !url.port || !database || database.includes('/') ||
|
|
34
|
+
!role || apiKey.length > 1 || apiKey[0] === '' || !password ||
|
|
35
|
+
[database, user, password].some((value) => controls.test(value)) || url.hash || parameters.length !== 2 ||
|
|
36
|
+
url.searchParams.get('sslmode') !== 'verify-full' || url.searchParams.get('sslrootcert') !== 'system') {
|
|
37
|
+
throw new Error('Ardent API returned an unsupported branch connection.');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
PGHOST: url.hostname,
|
|
41
|
+
PGPORT: url.port,
|
|
42
|
+
PGDATABASE: database,
|
|
43
|
+
PGUSER: user,
|
|
44
|
+
PGPASSWORD: password,
|
|
45
|
+
PGSSLMODE: 'verify-full',
|
|
46
|
+
PGSSLROOTCERT: 'system',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// libpqTooOld names why an older libpq cannot connect and how to fix it. A
|
|
50
|
+
// branch's certificate is from a public CA, so verifying it means trusting the
|
|
51
|
+
// platform's roots, which a URI asks for with sslrootcert=system; libpq reads
|
|
52
|
+
// that value only from 16 on, and an older one looks for a file named
|
|
53
|
+
// "system". A packaged psql requires a libpq at least its own version, so the
|
|
54
|
+
// psql version is the floor checked before any credential is issued; the TLS
|
|
55
|
+
// refusal itself is recognised too, for a psql linked against an older libpq.
|
|
56
|
+
export function libpqTooOld(found) {
|
|
57
|
+
return `${found} cannot verify Ardent's certificate: branch connect needs psql 16 or newer, with libpq 16 or newer, ` +
|
|
58
|
+
'because only libpq 16 and later check a server against the system\'s trusted roots (sslrootcert=system). ' +
|
|
59
|
+
'libpq 14 and 15 are the defaults on Ubuntu 22.04 and Debian 12. Install the PostgreSQL 16 or newer client ' +
|
|
60
|
+
'(for example postgresql-client-17 from apt.postgresql.org, or brew install libpq), then retry. ' +
|
|
61
|
+
'An application driver needs no psql: run ardent-beta branch connect --print --driver <libpq|node|jdbc|prisma>.';
|
|
62
|
+
}
|
|
63
|
+
const libpqRefusedSystemRoots = /root certificate file "system" does not exist/;
|
|
64
|
+
export async function checkPsql() {
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn('script', terminalCommand(['--version']), { stdio: ['inherit', 'pipe', 'ignore'] });
|
|
67
|
+
let version = '';
|
|
68
|
+
child.stdout.setEncoding('utf8');
|
|
69
|
+
child.stdout.on('data', (chunk) => { version += chunk.slice(0, 512 - version.length); });
|
|
70
|
+
child.once('error', () => reject(new Error('Install psql 16+ and the system script utility before connecting to a branch.')));
|
|
71
|
+
child.once('close', (code) => {
|
|
72
|
+
const major = /psql \(PostgreSQL\) (\d+)(?:\.|\s|$)/.exec(version)?.[1];
|
|
73
|
+
if (code === 0 && major && Number(major) >= 16)
|
|
74
|
+
resolve();
|
|
75
|
+
else if (code === 0 && major)
|
|
76
|
+
reject(new Error(libpqTooOld(`psql ${major}`)));
|
|
77
|
+
else
|
|
78
|
+
reject(new Error('Branch connect requires psql 16 or newer and the system script utility.'));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export async function runPsql(connection) {
|
|
83
|
+
// The API key is embedded in PGUSER. Neither the URI nor PGUSER reaches argv.
|
|
84
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('PG')));
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
const child = spawn('script', terminalCommand(['-X', '-v', 'PROMPT1=ardent=> ', '-v', 'PROMPT2=ardent-> ', '-v', 'PROMPT3=ardent>> ']), {
|
|
87
|
+
env: { ...environment, ...connection },
|
|
88
|
+
// script gives psql a terminal and writes its transcript only to /dev/null.
|
|
89
|
+
// Both wrapper outputs pass through the redactor before reaching ours.
|
|
90
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
91
|
+
});
|
|
92
|
+
// A passthrough username carries no API key; the username itself is
|
|
93
|
+
// still redacted.
|
|
94
|
+
const separator = connection.PGUSER.indexOf('+');
|
|
95
|
+
const apiKey = separator === -1 ? '' : connection.PGUSER.slice(separator + 1);
|
|
96
|
+
const secrets = [connection.PGUSER, connection.PGPASSWORD, apiKey].filter(Boolean);
|
|
97
|
+
// The last output text, unredacted and never printed, kept only to
|
|
98
|
+
// recognise libpq's refusal of sslrootcert=system. script runs psql on a
|
|
99
|
+
// terminal, which merges psql's stderr into script's stdout, so both
|
|
100
|
+
// streams are watched.
|
|
101
|
+
let outputTail = '';
|
|
102
|
+
const watch = (chunk) => { outputTail = (outputTail + chunk.toString()).slice(-1024); };
|
|
103
|
+
child.stdout.on('data', watch);
|
|
104
|
+
child.stderr.on('data', watch);
|
|
105
|
+
const redact = (source, target) => {
|
|
106
|
+
let pending = '';
|
|
107
|
+
const scrub = (chunk, ending = false) => {
|
|
108
|
+
let output = '';
|
|
109
|
+
for (const character of chunk) {
|
|
110
|
+
pending += character;
|
|
111
|
+
while (pending) {
|
|
112
|
+
const complete = secrets.find((secret) => pending.startsWith(secret));
|
|
113
|
+
const possibleLonger = secrets.some((secret) => secret.length > pending.length && secret.startsWith(pending));
|
|
114
|
+
if (possibleLonger)
|
|
115
|
+
break;
|
|
116
|
+
if (complete) {
|
|
117
|
+
output += '[redacted]';
|
|
118
|
+
pending = pending.slice(complete.length);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
output += pending[0];
|
|
122
|
+
pending = pending.slice(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ending && pending) {
|
|
127
|
+
output += secrets.some((secret) => secret.startsWith(pending)) ? '[redacted]' : pending;
|
|
128
|
+
pending = '';
|
|
129
|
+
}
|
|
130
|
+
if (output)
|
|
131
|
+
target.write(output);
|
|
132
|
+
};
|
|
133
|
+
source.setEncoding('utf8');
|
|
134
|
+
source.on('data', (chunk) => scrub(chunk));
|
|
135
|
+
return () => scrub('', true);
|
|
136
|
+
};
|
|
137
|
+
const flushStdout = redact(child.stdout, process.stdout);
|
|
138
|
+
const flushStderr = redact(child.stderr, process.stderr);
|
|
139
|
+
const keepParentAlive = () => { };
|
|
140
|
+
const stopChild = () => { child.kill('SIGTERM'); };
|
|
141
|
+
process.on('SIGINT', keepParentAlive);
|
|
142
|
+
process.on('SIGTERM', stopChild);
|
|
143
|
+
process.on('SIGHUP', stopChild);
|
|
144
|
+
child.once('error', () => reject(new Error('Could not launch psql. Check that it is installed and on PATH.')));
|
|
145
|
+
child.once('close', (code, signal) => {
|
|
146
|
+
process.off('SIGINT', keepParentAlive);
|
|
147
|
+
process.off('SIGTERM', stopChild);
|
|
148
|
+
process.off('SIGHUP', stopChild);
|
|
149
|
+
flushStdout();
|
|
150
|
+
flushStderr();
|
|
151
|
+
if (code === 0)
|
|
152
|
+
resolve();
|
|
153
|
+
else if (libpqRefusedSystemRoots.test(outputTail))
|
|
154
|
+
reject(new Error(libpqTooOld('This psql\'s libpq')));
|
|
155
|
+
else
|
|
156
|
+
reject(new Error(signal ? `psql stopped after signal ${signal}.` : `psql exited with status ${code}.`));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CredentialRecovery {
|
|
2
|
+
file: string;
|
|
3
|
+
branchID: string;
|
|
4
|
+
requestID: string;
|
|
5
|
+
issuanceID?: string;
|
|
6
|
+
sourceRole?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class CredentialRecoveryCleanupError extends Error {
|
|
9
|
+
readonly stage: 'remove' | 'sync';
|
|
10
|
+
constructor(stage: 'remove' | 'sync', cause: unknown);
|
|
11
|
+
}
|
|
12
|
+
export declare function openCredentialWitness(requestID: string): Promise<{
|
|
13
|
+
port: number;
|
|
14
|
+
close: () => Promise<void>;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function rememberCredentialRequest(directory: string, branchID: string, token: string, port: number, requestID: string, sourceRole?: string): Promise<CredentialRecovery>;
|
|
17
|
+
export declare function rememberCredentialIssuance(recovery: CredentialRecovery, issuanceID: string): Promise<void>;
|
|
18
|
+
export declare function forgetCredentialRecovery(recovery: CredentialRecovery): Promise<void>;
|
|
19
|
+
export declare function recoverBranchCredentials(directory: string, token: string): Promise<number>;
|