ardent-cli-beta 0.0.20 → 0.1.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/README.md +24 -10
- package/dist/branches/client.d.ts +3 -0
- package/dist/branches/client.js +44 -0
- package/dist/branches/connection.d.ts +2 -0
- package/dist/branches/connection.js +15 -6
- package/dist/branches/credentials.d.ts +34 -3
- package/dist/branches/credentials.js +114 -11
- package/dist/branches/psql.js +3 -2
- package/dist/branches/recovery.d.ts +3 -2
- package/dist/branches/recovery.js +14 -8
- package/dist/commands/branch/delete.js +22 -3
- package/oclif.manifest.json +2 -2
- package/openapi.json +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,12 @@ omitting it preserves unrestricted ingress. `--print` remains accepted. The cred
|
|
|
39
39
|
after exit. This uses `branch connect --print`'s lifecycle and requires a control
|
|
40
40
|
plane that issues credentials while branches start (`docs/DECISIONS.md` section 17).
|
|
41
41
|
|
|
42
|
+
For tokenized credentials, the CLI receives the database token once, keeps it
|
|
43
|
+
in memory while waiting for readiness, and composes the delivered URL template
|
|
44
|
+
locally. A lost one-time token requires revoking and issuing another credential;
|
|
45
|
+
recovery records contain only request and issuance metadata, never that token
|
|
46
|
+
(`docs/DECISIONS.md` section 20).
|
|
47
|
+
|
|
42
48
|
Use `branch create <name> --to-env` or `branch connect --to-env` to write
|
|
43
49
|
`DATABASE_URL` to `.env` in the
|
|
44
50
|
current directory instead of printing the URL. The selected `--driver` still
|
|
@@ -66,17 +72,21 @@ only the libpq form opens psql. Otherwise the credential stays out of arguments
|
|
|
66
72
|
variables ... and redacts the username, API key and password from both `script` output
|
|
67
73
|
streams". It does not stop a user from printing a credential
|
|
68
74
|
deliberately through SQL or a shell escape. A mode-0600 recovery
|
|
69
|
-
file holds only request and issuance IDs, and
|
|
75
|
+
file holds only request and issuance IDs, and the source role name or access profile its request named, until revocation succeeds, and the next `branch connect`
|
|
70
76
|
attempts to revoke every abandoned issuance before requesting another. A forced process kill
|
|
71
77
|
can leave a credential active until that recovery runs. Failed cleanup retains
|
|
72
78
|
the file and names the issuance ID for manual revocation.
|
|
73
79
|
|
|
74
|
-
[openapi.json](openapi.json) ships in the npm package.
|
|
75
|
-
|
|
76
|
-
`c8510ab3e2422756a93e1df09acf994cf6bac34d
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
[openapi.json](openapi.json) ships in the npm package. Its base is the
|
|
81
|
+
2026-09-24 control-plane documentation snapshot from Ardent's private source
|
|
82
|
+
revision `c8510ab3e2422756a93e1df09acf994cf6bac34d`. The branch-creation
|
|
83
|
+
description, accepted source-position fields, and seed-origin description were
|
|
84
|
+
selectively updated from the reviewed control-plane specification at revision
|
|
85
|
+
`32da7390a` for ARD-2378. The rest was not refreshed, and this file is not
|
|
86
|
+
proof of deployed compatibility. It still lacks the branch credential endpoints
|
|
87
|
+
used by `branch connect`, the branch wake, suspend and recover operations used by
|
|
88
|
+
those commands, and connector deletion; the source specification documents
|
|
89
|
+
wake, suspend and recover, and
|
|
80
90
|
refreshing this snapshot from a merged revision that carries them is owed.
|
|
81
91
|
Documenting the credential and connector-deletion endpoints in the source
|
|
82
92
|
specification is also owed. To refresh it, replace it with the reviewed
|
|
@@ -130,10 +140,14 @@ report source cleanup residue; the delete command warns about it and status
|
|
|
130
140
|
preserves its code in text and JSON. A failed or interrupted wait retains local
|
|
131
141
|
context and names the status command before retrying.
|
|
132
142
|
|
|
133
|
-
Creation
|
|
134
|
-
|
|
143
|
+
Creation asks the API for a branch at its recent applied source position. The
|
|
144
|
+
API uses a published mark covering that position or requests one on demand; an
|
|
145
|
+
accepted operation waits for that mark before materialization. Without recent
|
|
146
|
+
source progress, the API refuses creation. Names are unique within
|
|
135
147
|
a connector while the branch is not deleted. `branch delete` sends the connector
|
|
136
|
-
and name to the API, which resolves the target.
|
|
148
|
+
and name to the API, which resolves the target. When no branch has that name and
|
|
149
|
+
the argument is the ID of a deleted branch whose erasure is unconfirmed, as a
|
|
150
|
+
refused `connector delete` names one, it retries that erasure. `branch list` fetches current state
|
|
137
151
|
and marks the locally selected branch with `●` in its `Selected` column; `branch switch` changes local
|
|
138
152
|
context. Create and delete follow the operation in an interactive terminal;
|
|
139
153
|
creation also waits outside a terminal and with `--json` so it can return a usable
|
|
@@ -15,6 +15,9 @@ export interface BranchOperation {
|
|
|
15
15
|
export declare function listBranches(token: string, connectorID: string): Promise<Branch[]>;
|
|
16
16
|
export declare function createBranch(token: string, connectorID: string, name: string, allowedCIDRs?: string[]): Promise<BranchOperation>;
|
|
17
17
|
export declare function deleteBranch(token: string, connectorID: string, name: string): Promise<BranchOperation>;
|
|
18
|
+
export declare function isBranchID(value: string): boolean;
|
|
19
|
+
export declare function loadDeletedBranch(token: string, connectorID: string, id: string): Promise<Branch>;
|
|
20
|
+
export declare function retryBranchErasure(token: string, branch: Branch): Promise<BranchOperation>;
|
|
18
21
|
export type BranchLifecycleAction = 'wake' | 'suspend' | 'recover';
|
|
19
22
|
export declare function transitionBranch(token: string, connectorID: string, name: string, action: BranchLifecycleAction): Promise<BranchOperation>;
|
|
20
23
|
export declare function waitForBranchOperation(token: string, workflowID: string, onStatus?: (status: string) => void, failedReads?: 'stop' | 'retry'): Promise<void>;
|
package/dist/branches/client.js
CHANGED
|
@@ -23,6 +23,50 @@ export async function deleteBranch(token, connectorID, name) {
|
|
|
23
23
|
validateOperation(result, connectorID, name);
|
|
24
24
|
return result;
|
|
25
25
|
}
|
|
26
|
+
// A deleted branch whose erasure no node has confirmed is in no branch list
|
|
27
|
+
// and unreachable by name, yet keeps its connector from being deleted; that
|
|
28
|
+
// refusal names it by ID. These two calls resolve such an ID and retry its
|
|
29
|
+
// erasure by ID. docs/DECISIONS.md section 19 says why only a DELETED branch.
|
|
30
|
+
export function isBranchID(value) {
|
|
31
|
+
return /^branch_[0-9A-Za-z_-]+$/.test(value);
|
|
32
|
+
}
|
|
33
|
+
export async function loadDeletedBranch(token, connectorID, id) {
|
|
34
|
+
const missing = `No branch ${terminalText(id)} in the selected connector.`;
|
|
35
|
+
let branch;
|
|
36
|
+
try {
|
|
37
|
+
branch = await request(token, `/branches/${encodeURIComponent(id)}`);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof APIError && error.status === 404)
|
|
41
|
+
throw new Error(missing, { cause: error });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (!isBranch(branch, connectorID) || branch.id !== id)
|
|
45
|
+
throw new Error(missing);
|
|
46
|
+
if (branch.state !== 'DELETED')
|
|
47
|
+
throw new Error(`Branch ${terminalText(id)} is not deleted. Delete it by its name, ${terminalText(JSON.stringify(branch.name))}.`);
|
|
48
|
+
return branch;
|
|
49
|
+
}
|
|
50
|
+
export async function retryBranchErasure(token, branch) {
|
|
51
|
+
let operation;
|
|
52
|
+
try {
|
|
53
|
+
// branch list omits a deleted branch and no CLI command reads one, so the
|
|
54
|
+
// refresh is the read loadDeletedBranch made.
|
|
55
|
+
operation = await requestDurableMutation(token, `/branches/${encodeURIComponent(branch.id)}`, { method: 'DELETE' }, `an authenticated GET /branches/${terminalText(encodeURIComponent(branch.id))}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// This route refuses in plain text, so the API's reason does not reach here.
|
|
59
|
+
if (error instanceof APIError && error.status === 409) {
|
|
60
|
+
throw new Error(`The Ardent API refused to retry the erasure of branch ${terminalText(branch.id)}. An erasure still running can be retried once it fails, an hour after it was requested; otherwise report the branch ID to Ardent support.`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
if (!operation || typeof operation.operation_id !== 'string' || !operation.operation_id ||
|
|
65
|
+
typeof operation.workflow_id !== 'string' || !operation.workflow_id) {
|
|
66
|
+
throw new Error(`The Ardent API accepted the erasure retry of branch ${terminalText(branch.id)} but returned no valid operation; completion is unknown and work may still be running.`);
|
|
67
|
+
}
|
|
68
|
+
return { branch, operation };
|
|
69
|
+
}
|
|
26
70
|
// The control plane addresses these transitions by branch ID, so the command
|
|
27
71
|
// resolves the name in the selected connector first and acts on exactly that
|
|
28
72
|
// branch. The response is the operation alone; the resolved branch is returned
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Unadmitted } from './credentials.js';
|
|
1
2
|
import { type Driver } from './drivers.js';
|
|
2
3
|
export interface BranchConnection {
|
|
3
4
|
configDir: string;
|
|
@@ -17,5 +18,6 @@ export interface PrintedConnection {
|
|
|
17
18
|
issuance_id: string;
|
|
18
19
|
driver: Driver;
|
|
19
20
|
connection_string: string;
|
|
21
|
+
unadmitted?: Unadmitted;
|
|
20
22
|
}
|
|
21
23
|
export declare function connectToBranch(connection: BranchConnection): Promise<PrintedConnection | void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { acknowledgeBranchCredential, CredentialIssueRefused, issueBranchCredential, revokeBranchCredential, waitForBranchCredential } from './credentials.js';
|
|
2
|
+
import { acknowledgeBranchCredential, ALL_SCHEMAS_PROFILE, composeCredentialDelivery, CredentialIssueRefused, describeUnadmitted, issuedConnectKey, issueBranchCredential, revokeBranchCredential, waitForBranchCredential } from './credentials.js';
|
|
3
3
|
import { CLIENT_REQUIREMENTS, connectionString } from './drivers.js';
|
|
4
4
|
import { connectionEnvironment, runPsql } from './psql.js';
|
|
5
5
|
import { CredentialRecoveryCleanupError, forgetCredentialRecovery, openCredentialWitness, rememberCredentialIssuance, rememberCredentialRequest } from './recovery.js';
|
|
@@ -11,10 +11,14 @@ import { terminalText } from '../terminal.js';
|
|
|
11
11
|
export async function connectToBranch(connection) {
|
|
12
12
|
const { token, branchID, sourceRole, print, driver, say, warn } = connection;
|
|
13
13
|
const requestID = randomUUID();
|
|
14
|
+
// Without a source role, branch connect asks for every in-scope schema, and
|
|
15
|
+
// never retries with another profile: a refusal tells the user to choose.
|
|
16
|
+
const credential = sourceRole === undefined ? { accessProfile: ALL_SCHEMAS_PROFILE } : { sourceRole };
|
|
14
17
|
const witness = await openCredentialWitness(requestID);
|
|
15
18
|
try {
|
|
16
|
-
const recovery = await rememberCredentialRequest(connection.configDir, branchID, token, witness.port, requestID,
|
|
19
|
+
const recovery = await rememberCredentialRequest(connection.configDir, branchID, token, witness.port, requestID, credential);
|
|
17
20
|
let issuanceID;
|
|
21
|
+
let connectKey;
|
|
18
22
|
let issueRefused = false;
|
|
19
23
|
// Set once a printed credential's recovery record is gone: the
|
|
20
24
|
// credential is the user's from then on, and nothing here revokes it.
|
|
@@ -25,7 +29,10 @@ export async function connectToBranch(connection) {
|
|
|
25
29
|
let failure;
|
|
26
30
|
try {
|
|
27
31
|
try {
|
|
28
|
-
|
|
32
|
+
const issued = await issueBranchCredential(token, branchID, requestID, credential);
|
|
33
|
+
issuanceID = issued.issuanceID;
|
|
34
|
+
await rememberCredentialIssuance(recovery, issuanceID);
|
|
35
|
+
connectKey = issuedConnectKey(issued);
|
|
29
36
|
}
|
|
30
37
|
catch (error) {
|
|
31
38
|
if (error instanceof CredentialIssueRefused) {
|
|
@@ -42,8 +49,10 @@ export async function connectToBranch(connection) {
|
|
|
42
49
|
}
|
|
43
50
|
throw error;
|
|
44
51
|
}
|
|
45
|
-
await
|
|
46
|
-
const
|
|
52
|
+
const delivery = composeCredentialDelivery(await deliverWhenServing(token, issuanceID, connection.serving), connectKey);
|
|
53
|
+
const unadmitted = describeUnadmitted(delivery.unadmitted);
|
|
54
|
+
if (unadmitted)
|
|
55
|
+
say(unadmitted);
|
|
47
56
|
if (print) {
|
|
48
57
|
// Checked before acknowledgement, so a form that would not verify
|
|
49
58
|
// the server is revoked, never handed over.
|
|
@@ -71,7 +80,7 @@ export async function connectToBranch(connection) {
|
|
|
71
80
|
say(`The ${driver} form needs, in your client:\n${CLIENT_REQUIREMENTS[driver].map((requirement) => ` - ${requirement}`).join('\n')}`);
|
|
72
81
|
}
|
|
73
82
|
if (connection.json)
|
|
74
|
-
return { issuance_id: issuanceID, driver, connection_string: value };
|
|
83
|
+
return { issuance_id: issuanceID, driver, connection_string: value, ...(delivery.unadmitted ? { unadmitted: delivery.unadmitted } : {}) };
|
|
75
84
|
connection.log(value);
|
|
76
85
|
return;
|
|
77
86
|
}
|
|
@@ -1,18 +1,49 @@
|
|
|
1
1
|
import { type ConnectionStrings } from './drivers.js';
|
|
2
|
-
interface
|
|
2
|
+
export interface IssuedCredential {
|
|
3
|
+
issuanceID: string;
|
|
4
|
+
connectKey?: unknown;
|
|
5
|
+
connectKeyRequired?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface Unadmitted {
|
|
8
|
+
count: number;
|
|
9
|
+
overflow: boolean;
|
|
10
|
+
schemas?: {
|
|
11
|
+
schema: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
}[];
|
|
14
|
+
objects?: {
|
|
15
|
+
object: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
export interface DeliveredCredential {
|
|
3
21
|
issuance_id: string;
|
|
4
22
|
delivery_id: string;
|
|
23
|
+
access_profile?: string;
|
|
5
24
|
uri: string;
|
|
25
|
+
unadmitted?: Unadmitted;
|
|
6
26
|
connection_strings?: ConnectionStrings;
|
|
27
|
+
connect_key_required?: boolean;
|
|
7
28
|
}
|
|
8
29
|
export declare class CredentialIssueRefused extends Error {
|
|
9
30
|
readonly code?: string | undefined;
|
|
10
31
|
constructor(message: string, code?: string | undefined);
|
|
11
32
|
}
|
|
33
|
+
export declare const ALL_SCHEMAS_PROFILE = "all_schemas_read_write_v1";
|
|
34
|
+
export declare const PUBLIC_READ_WRITE_PROFILE = "public_read_write_v1";
|
|
35
|
+
export type AccessProfile = typeof ALL_SCHEMAS_PROFILE | typeof PUBLIC_READ_WRITE_PROFILE;
|
|
36
|
+
export type CredentialRequest = {
|
|
37
|
+
sourceRole: string;
|
|
38
|
+
} | {
|
|
39
|
+
accessProfile: AccessProfile;
|
|
40
|
+
};
|
|
12
41
|
export declare function validSourceRole(role: string): boolean;
|
|
13
42
|
export declare function refusedBeforeIssuance(error: unknown): boolean;
|
|
14
|
-
export declare function issueBranchCredential(token: string, branchID: string, requestID: string,
|
|
43
|
+
export declare function issueBranchCredential(token: string, branchID: string, requestID: string, credential: CredentialRequest): Promise<IssuedCredential>;
|
|
44
|
+
export declare function issuedConnectKey(issued: IssuedCredential): string | undefined;
|
|
45
|
+
export declare function composeCredentialDelivery(delivered: DeliveredCredential, connectKey?: string): DeliveredCredential;
|
|
15
46
|
export declare function waitForBranchCredential(token: string, issuanceID: string, stop?: AbortSignal, served?: Promise<void>): Promise<DeliveredCredential>;
|
|
47
|
+
export declare function describeUnadmitted(unadmitted: Unadmitted | undefined): string | undefined;
|
|
16
48
|
export declare function acknowledgeBranchCredential(token: string, issuanceID: string, deliveryID: string): Promise<void>;
|
|
17
49
|
export declare function revokeBranchCredential(token: string, issuanceID: string): Promise<void>;
|
|
18
|
-
export {};
|
|
@@ -11,7 +11,19 @@ export class CredentialIssueRefused extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
// The issuance refusals that come before any issuance is recorded: a request
|
|
13
13
|
// refused with one of these left nothing to revoke.
|
|
14
|
-
const
|
|
14
|
+
const issueRefusals = new Set(['invalid_source_role', 'source_role_unsupported', 'source_role_held', 'source_role_revocation_pending', 'connect_key_unsupported',
|
|
15
|
+
'all_schemas_read_write_unsupported', 'all_schemas_read_write_unowned']);
|
|
16
|
+
// The access profile branch connect asks for without --role, and the one an
|
|
17
|
+
// earlier release of this CLI asked for, which a recovery record naming no
|
|
18
|
+
// profile replays.
|
|
19
|
+
export const ALL_SCHEMAS_PROFILE = 'all_schemas_read_write_v1';
|
|
20
|
+
export const PUBLIC_READ_WRITE_PROFILE = 'public_read_write_v1';
|
|
21
|
+
// What branch connect says when the API refuses the profile it asks for. The
|
|
22
|
+
// CLI never retries with another profile: the user chooses one.
|
|
23
|
+
const profileRefusals = {
|
|
24
|
+
all_schemas_read_write_unsupported: 'This branch\'s environment runs an Ardent release that cannot issue a credential covering every schema. Upgrade the environment, or connect as a source role with --role (for example --role postgres).',
|
|
25
|
+
all_schemas_read_write_unowned: 'This branch was created without restoring its source\'s owners, so Ardent cannot tell which of its schemas belong to the database platform and will not issue a credential covering every schema. Connect as a source role with --role (for example --role postgres).',
|
|
26
|
+
};
|
|
15
27
|
// sourceRolePattern is the name the API accepts for a source role: an
|
|
16
28
|
// unquoted lower-case PostgreSQL identifier that begins with neither pg_ nor
|
|
17
29
|
// ardent. The CLI checks it before it records or sends anything, so a mistyped
|
|
@@ -21,7 +33,7 @@ export function validSourceRole(role) {
|
|
|
21
33
|
return sourceRolePattern.test(role);
|
|
22
34
|
}
|
|
23
35
|
export function refusedBeforeIssuance(error) {
|
|
24
|
-
return error instanceof CredentialIssueRefused && error.code !== undefined &&
|
|
36
|
+
return error instanceof CredentialIssueRefused && error.code !== undefined && issueRefusals.has(error.code);
|
|
25
37
|
}
|
|
26
38
|
function credentialPath(issuanceID) {
|
|
27
39
|
return `/branch-credentials/${encodeURIComponent(issuanceID)}`;
|
|
@@ -40,20 +52,76 @@ function safeRequest(token, path, init) {
|
|
|
40
52
|
throw new Error('Branch credential request failed. Check the API connection and try again.');
|
|
41
53
|
});
|
|
42
54
|
}
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// request must name the same
|
|
46
|
-
export async function issueBranchCredential(token, branchID, requestID,
|
|
47
|
-
const body = sourceRole
|
|
48
|
-
? { request_id: requestID,
|
|
49
|
-
: { request_id: requestID,
|
|
55
|
+
// A source role request asks for a fresh branch-only password on that source
|
|
56
|
+
// role; any other asks for an issued role under the access profile it names.
|
|
57
|
+
// A replay of the same request must name the same.
|
|
58
|
+
export async function issueBranchCredential(token, branchID, requestID, credential) {
|
|
59
|
+
const body = 'sourceRole' in credential
|
|
60
|
+
? { request_id: requestID, source_role: credential.sourceRole }
|
|
61
|
+
: { request_id: requestID, access_profile: credential.accessProfile };
|
|
50
62
|
const result = await safeRequest(token, `/branches/${encodeURIComponent(branchID)}/credentials`, {
|
|
51
63
|
method: 'POST', body: JSON.stringify(body),
|
|
64
|
+
}).catch((error) => {
|
|
65
|
+
if (error instanceof CredentialIssueRefused && error.code !== undefined && profileRefusals[error.code] !== undefined) {
|
|
66
|
+
throw new CredentialIssueRefused(`${profileRefusals[error.code]} (${error.code})`, error.code);
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
52
69
|
});
|
|
53
70
|
if (!result || typeof result.issuance_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result.issuance_id) || result.status !== 'pending') {
|
|
54
71
|
throw new Error('Ardent API returned an invalid branch credential issuance.');
|
|
55
72
|
}
|
|
56
|
-
|
|
73
|
+
// Return the identity before checking the one-time secret, so the caller
|
|
74
|
+
// records and revokes an issuance even when its secret is absent or malformed.
|
|
75
|
+
return { issuanceID: result.issuance_id, connectKey: result.connect_key, connectKeyRequired: result.connect_key_required };
|
|
76
|
+
}
|
|
77
|
+
export function issuedConnectKey(issued) {
|
|
78
|
+
if (issued.connectKeyRequired === undefined || issued.connectKeyRequired === false) {
|
|
79
|
+
if (issued.connectKey !== undefined)
|
|
80
|
+
throw new Error('Ardent API returned an unexpected database token. Revoke this credential and issue another.');
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (issued.connectKeyRequired !== true || typeof issued.connectKey !== 'string' ||
|
|
84
|
+
!/^bck_[a-z2-7]{16}_[A-Za-z0-9_-]{43}$/.test(issued.connectKey) ||
|
|
85
|
+
Buffer.from(issued.connectKey.slice(21), 'base64url').toString('base64url') !== issued.connectKey.slice(21)) {
|
|
86
|
+
throw new Error('The one-time database token was not received. Revoke this credential and issue another; the token cannot be recovered.');
|
|
87
|
+
}
|
|
88
|
+
return issued.connectKey;
|
|
89
|
+
}
|
|
90
|
+
// Tokenized delivery contains templates, never a recoverable database token.
|
|
91
|
+
// Compose only the username field locally; neither password nor query settings
|
|
92
|
+
// are interpolated. The ordinary driver checks still validate TLS afterwards.
|
|
93
|
+
export function composeCredentialDelivery(delivered, connectKey) {
|
|
94
|
+
if (delivered.connect_key_required !== undefined && typeof delivered.connect_key_required !== 'boolean') {
|
|
95
|
+
throw new Error('Ardent API returned an invalid database token requirement.');
|
|
96
|
+
}
|
|
97
|
+
if (Boolean(connectKey) !== (delivered.connect_key_required === true)) {
|
|
98
|
+
throw new Error('Ardent API returned an inconsistent database token requirement.');
|
|
99
|
+
}
|
|
100
|
+
if (!connectKey)
|
|
101
|
+
return delivered;
|
|
102
|
+
const compose = (value, jdbc = false) => {
|
|
103
|
+
try {
|
|
104
|
+
if (jdbc && !value.startsWith('jdbc:postgresql://'))
|
|
105
|
+
throw new Error();
|
|
106
|
+
const url = new URL(jdbc ? value.slice('jdbc:'.length) : value);
|
|
107
|
+
const user = jdbc ? url.searchParams.get('user') : decodeURIComponent(url.username);
|
|
108
|
+
if (!user || !/^[a-z_][a-z0-9_]{0,62}\+<connect-key>$/.test(user) || (jdbc && url.searchParams.getAll('user').length !== 1))
|
|
109
|
+
throw new Error();
|
|
110
|
+
const username = `${user.slice(0, -'<connect-key>'.length)}${connectKey}`;
|
|
111
|
+
if (jdbc)
|
|
112
|
+
url.searchParams.set('user', username);
|
|
113
|
+
else
|
|
114
|
+
url.username = username;
|
|
115
|
+
return `${jdbc ? 'jdbc:' : ''}${url.toString()}`;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new Error('Ardent API returned an invalid database token template.');
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const uri = compose(delivered.uri);
|
|
122
|
+
const strings = delivered.connection_strings;
|
|
123
|
+
const connectionStrings = strings && Object.fromEntries(DRIVERS.map((driver) => [driver, compose(strings[driver], driver === 'jdbc')]));
|
|
124
|
+
return { ...delivered, uri, connection_strings: connectionStrings };
|
|
57
125
|
}
|
|
58
126
|
// stop, when given, ends the wait with its abort reason: a caller that learns
|
|
59
127
|
// the branch failed has no delivery left to wait for. served, when given,
|
|
@@ -133,12 +201,47 @@ export async function waitForBranchCredential(token, issuanceID, stop, served) {
|
|
|
133
201
|
DRIVERS.every((driver) => typeof strings[driver] === 'string' && strings[driver] !== '') &&
|
|
134
202
|
strings.libpq === delivered.uri);
|
|
135
203
|
if (delivered.issuance_id !== issuanceID || typeof delivered.delivery_id !== 'string' || !delivered.delivery_id ||
|
|
136
|
-
typeof delivered.uri !== 'string' || !delivered.uri || !stringsValid
|
|
204
|
+
typeof delivered.uri !== 'string' || !delivered.uri || !stringsValid ||
|
|
205
|
+
(delivered.access_profile !== undefined && typeof delivered.access_profile !== 'string') ||
|
|
206
|
+
(delivered.unadmitted !== undefined && !validUnadmitted(delivered.unadmitted)))
|
|
137
207
|
throw new Error('Ardent API returned an invalid branch credential delivery.');
|
|
138
208
|
return delivered;
|
|
139
209
|
}
|
|
140
210
|
throw new Error('Timed out waiting for a branch credential. Check branch status before trying again.');
|
|
141
211
|
}
|
|
212
|
+
// validUnadmitted holds a delivered unadmitted list to its shape: a positive
|
|
213
|
+
// count, an overflow flag, and lists of non-empty strings when present, which
|
|
214
|
+
// together name no more than count.
|
|
215
|
+
function validUnadmitted(value) {
|
|
216
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
217
|
+
return false;
|
|
218
|
+
const list = value;
|
|
219
|
+
const strings = (entry, keys) => typeof entry === 'object' && entry !== null &&
|
|
220
|
+
keys.every((key) => typeof entry[key] === 'string' && entry[key] !== '');
|
|
221
|
+
const entries = (field, keys) => field === undefined || (Array.isArray(field) && field.every((entry) => strings(entry, keys)));
|
|
222
|
+
if (!Number.isSafeInteger(list.count) || list.count < 1 || typeof list.overflow !== 'boolean' ||
|
|
223
|
+
!entries(list.schemas, ['schema', 'reason']) || !entries(list.objects, ['object', 'kind', 'reason']))
|
|
224
|
+
return false;
|
|
225
|
+
const named = (list.schemas?.length ?? 0) + (list.objects?.length ?? 0);
|
|
226
|
+
return named <= list.count;
|
|
227
|
+
}
|
|
228
|
+
// describeUnadmitted is what branch connect says about a delivered
|
|
229
|
+
// credential's unadmitted list, or undefined when it leaves nothing out.
|
|
230
|
+
export function describeUnadmitted(unadmitted) {
|
|
231
|
+
if (!unadmitted)
|
|
232
|
+
return undefined;
|
|
233
|
+
const lines = [
|
|
234
|
+
...(unadmitted.schemas ?? []).map((entry) => ` schema ${terminalText(entry.schema)}: ${terminalText(entry.reason)}`),
|
|
235
|
+
...(unadmitted.objects ?? []).map((entry) => ` ${entry.kind === 'm' ? 'materialized view' : 'view'} ${terminalText(entry.object)}: ${terminalText(entry.reason)}`),
|
|
236
|
+
];
|
|
237
|
+
const head = `This credential has no access to ${unadmitted.count} schema(s) or view(s) on the branch that it would otherwise cover`;
|
|
238
|
+
if (lines.length === 0) {
|
|
239
|
+
return unadmitted.overflow
|
|
240
|
+
? `${head}; the list is too long to report by name.`
|
|
241
|
+
: `${head}; its node has not reported which yet. The branch's credential_unadmitted in the Ardent API names them once it has.`;
|
|
242
|
+
}
|
|
243
|
+
return `${head}:\n${lines.join('\n')}\nConnect with --role to reach them as a source role.`;
|
|
244
|
+
}
|
|
142
245
|
export async function acknowledgeBranchCredential(token, issuanceID, deliveryID) {
|
|
143
246
|
const deadline = Date.now() + 30_000;
|
|
144
247
|
while (true) {
|
package/dist/branches/psql.js
CHANGED
|
@@ -26,7 +26,8 @@ export function connectionEnvironment(uri) {
|
|
|
26
26
|
}
|
|
27
27
|
const parameters = [...url.searchParams.entries()];
|
|
28
28
|
const controls = /[\u0000-\u001f\u007f]/;
|
|
29
|
-
// A terminating environment's username is <role>+<
|
|
29
|
+
// A terminating environment's username is <role>+<database token> (or a
|
|
30
|
+
// legacy API key), which its node
|
|
30
31
|
// proxy splits; a passthrough environment's is the role alone, because no
|
|
31
32
|
// proxy is there to split it. Either way neither half may be empty.
|
|
32
33
|
const [role, ...apiKey] = user.split('+');
|
|
@@ -80,7 +81,7 @@ export async function checkPsql() {
|
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
export async function runPsql(connection) {
|
|
83
|
-
// The API key is
|
|
84
|
+
// The database token or legacy API key is in PGUSER. Neither it nor the URI reaches argv.
|
|
84
85
|
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('PG')));
|
|
85
86
|
await new Promise((resolve, reject) => {
|
|
86
87
|
const child = spawn('script', terminalCommand(['-X', '-v', 'PROMPT1=ardent=> ', '-v', 'PROMPT2=ardent-> ', '-v', 'PROMPT3=ardent>> ']), {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type CredentialRequest } from './credentials.js';
|
|
1
2
|
export interface CredentialRecovery {
|
|
2
3
|
file: string;
|
|
3
4
|
branchID: string;
|
|
4
5
|
requestID: string;
|
|
5
6
|
issuanceID?: string;
|
|
6
|
-
|
|
7
|
+
credential: CredentialRequest;
|
|
7
8
|
}
|
|
8
9
|
export declare class CredentialRecoveryCleanupError extends Error {
|
|
9
10
|
readonly stage: 'remove' | 'sync';
|
|
@@ -13,7 +14,7 @@ export declare function openCredentialWitness(requestID: string): Promise<{
|
|
|
13
14
|
port: number;
|
|
14
15
|
close: () => Promise<void>;
|
|
15
16
|
}>;
|
|
16
|
-
export declare function rememberCredentialRequest(directory: string, branchID: string, token: string, port: number, requestID: string,
|
|
17
|
+
export declare function rememberCredentialRequest(directory: string, branchID: string, token: string, port: number, requestID: string, credential: CredentialRequest): Promise<CredentialRecovery>;
|
|
17
18
|
export declare function rememberCredentialIssuance(recovery: CredentialRecovery, issuanceID: string): Promise<void>;
|
|
18
19
|
export declare function forgetCredentialRecovery(recovery: CredentialRecovery): Promise<void>;
|
|
19
20
|
export declare function recoverBranchCredentials(directory: string, token: string): Promise<number>;
|
|
@@ -3,7 +3,11 @@ import { link, open, readFile, readdir, rename, unlink, writeFile } from 'node:f
|
|
|
3
3
|
import { createConnection, createServer } from 'node:net';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { API_URL } from '../api/client.js';
|
|
6
|
-
import { issueBranchCredential, refusedBeforeIssuance, revokeBranchCredential, validSourceRole } from './credentials.js';
|
|
6
|
+
import { ALL_SCHEMAS_PROFILE, issueBranchCredential, PUBLIC_READ_WRITE_PROFILE, refusedBeforeIssuance, revokeBranchCredential, validSourceRole } from './credentials.js';
|
|
7
|
+
// recordedRequest is the record's fields for what a request named.
|
|
8
|
+
function recordedRequest(credential) {
|
|
9
|
+
return 'sourceRole' in credential ? { source_role: credential.sourceRole } : { access_profile: credential.accessProfile };
|
|
10
|
+
}
|
|
7
11
|
export class CredentialRecoveryCleanupError extends Error {
|
|
8
12
|
stage;
|
|
9
13
|
constructor(stage, cause) {
|
|
@@ -103,14 +107,14 @@ async function writeRecord(file, record, replace) {
|
|
|
103
107
|
});
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
|
-
export async function rememberCredentialRequest(directory, branchID, token, port, requestID,
|
|
110
|
+
export async function rememberCredentialRequest(directory, branchID, token, port, requestID, credential) {
|
|
107
111
|
const file = recoveryPath(directory, branchID, token, port, requestID);
|
|
108
|
-
await writeRecord(file, { request_id: requestID, ...(
|
|
109
|
-
return { file, branchID, requestID,
|
|
112
|
+
await writeRecord(file, { request_id: requestID, ...recordedRequest(credential) }, false);
|
|
113
|
+
return { file, branchID, requestID, credential };
|
|
110
114
|
}
|
|
111
115
|
export async function rememberCredentialIssuance(recovery, issuanceID) {
|
|
112
116
|
recovery.issuanceID = issuanceID;
|
|
113
|
-
await writeRecord(recovery.file, { request_id: recovery.requestID, issuance_id: issuanceID, ...(recovery.
|
|
117
|
+
await writeRecord(recovery.file, { request_id: recovery.requestID, issuance_id: issuanceID, ...recordedRequest(recovery.credential) }, true);
|
|
114
118
|
}
|
|
115
119
|
// forgetCredentialRecovery removes a record and syncs its directory. A record
|
|
116
120
|
// already removed, by an earlier attempt whose sync failed, is removed; the
|
|
@@ -165,7 +169,8 @@ export async function recoverBranchCredentials(directory, token) {
|
|
|
165
169
|
}
|
|
166
170
|
if (!record || typeof record !== 'object' || record.request_id !== requestID ||
|
|
167
171
|
(record.issuance_id !== undefined && (typeof record.issuance_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(record.issuance_id))) ||
|
|
168
|
-
(record.source_role !== undefined && (typeof record.source_role !== 'string' || !validSourceRole(record.source_role)))
|
|
172
|
+
(record.source_role !== undefined && (typeof record.source_role !== 'string' || !validSourceRole(record.source_role))) ||
|
|
173
|
+
(record.access_profile !== undefined && (record.source_role !== undefined || (record.access_profile !== ALL_SCHEMAS_PROFILE && record.access_profile !== PUBLIC_READ_WRITE_PROFILE)))) {
|
|
169
174
|
failures.push(`Invalid branch credential recovery file ${name}.`);
|
|
170
175
|
continue;
|
|
171
176
|
}
|
|
@@ -180,12 +185,13 @@ export async function recoverBranchCredentials(directory, token) {
|
|
|
180
185
|
failures.push(`Branch credential ${record.issuance_id ?? `request ${requestID}`} has a ${state === 'live' ? 'running session' : 'session whose state is unknown'}; finish or resolve that session before connecting again.`);
|
|
181
186
|
continue;
|
|
182
187
|
}
|
|
183
|
-
const
|
|
188
|
+
const credential = record.source_role !== undefined ? { sourceRole: record.source_role } : { accessProfile: record.access_profile ?? PUBLIC_READ_WRITE_PROFILE };
|
|
189
|
+
const recovery = { file, branchID, requestID, issuanceID: record.issuance_id, credential };
|
|
184
190
|
try {
|
|
185
191
|
if (!recovery.issuanceID) {
|
|
186
192
|
let issuanceID;
|
|
187
193
|
try {
|
|
188
|
-
issuanceID = await issueBranchCredential(token, branchID, requestID, recovery.
|
|
194
|
+
issuanceID = (await issueBranchCredential(token, branchID, requestID, recovery.credential)).issuanceID;
|
|
189
195
|
}
|
|
190
196
|
catch (error) {
|
|
191
197
|
// The API refuses a source role before it records an issuance and
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Args, ux } from '@oclif/core';
|
|
2
|
+
import { APIError } from '../../api/client.js';
|
|
2
3
|
import { ArdentCommand } from '../../command.js';
|
|
3
|
-
import { branchName, deleteBranch, waitForBranchOperation } from '../../branches/client.js';
|
|
4
|
+
import { branchName, deleteBranch, isBranchID, loadDeletedBranch, retryBranchErasure, waitForBranchOperation } from '../../branches/client.js';
|
|
4
5
|
import { replaceBranchCache } from '../../branches/context.js';
|
|
5
6
|
import { requireConnector } from '../../connectors/context.js';
|
|
6
7
|
import { terminalText } from '../../terminal.js';
|
|
7
8
|
export default class BranchDelete extends ArdentCommand {
|
|
8
|
-
static args = { name: Args.string({ description: 'Branch name', required: true }) };
|
|
9
|
+
static args = { name: Args.string({ description: 'Branch name, or the ID of a deleted branch whose erasure to retry', required: true }) };
|
|
9
10
|
static strict = false;
|
|
10
11
|
static description = 'Delete a branch in the selected connector';
|
|
11
12
|
static helpGroup = 'Branches';
|
|
@@ -20,7 +21,25 @@ export default class BranchDelete extends ArdentCommand {
|
|
|
20
21
|
else if (!this.jsonEnabled())
|
|
21
22
|
this.log(`Deleting branch ${target}...`);
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
result = await deleteBranch(session.token, session.selectedConnector.id, name);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// A name that matches no branch may be the ID of a deleted branch
|
|
30
|
+
// whose erasure is unconfirmed (retryBranchErasure).
|
|
31
|
+
if (!(error instanceof APIError && error.status === 404 && error.code === 'branch_not_found' && isBranchID(name)))
|
|
32
|
+
throw error;
|
|
33
|
+
const branch = await loadDeletedBranch(session.token, session.selectedConnector.id, name);
|
|
34
|
+
const retry = `Retrying the erasure of deleted branch ${terminalText(branch.name)} (${terminalText(branch.id)}) in connector ${terminalText(session.selectedConnector.name)}`;
|
|
35
|
+
if (interactive) {
|
|
36
|
+
ux.action.stop('no branch has that name');
|
|
37
|
+
ux.action.start(retry);
|
|
38
|
+
}
|
|
39
|
+
else if (!this.jsonEnabled())
|
|
40
|
+
this.log(`${retry}...`);
|
|
41
|
+
result = await retryBranchErasure(session.token, branch);
|
|
42
|
+
}
|
|
24
43
|
if (interactive) {
|
|
25
44
|
await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
|
|
26
45
|
await replaceBranchCache(this.config.configDir, session, (session.branches ?? []).filter(({ id }) => id !== result.branch.id));
|
package/oclif.manifest.json
CHANGED
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
"aliases": [],
|
|
240
240
|
"args": {
|
|
241
241
|
"name": {
|
|
242
|
-
"description": "Branch name",
|
|
242
|
+
"description": "Branch name, or the ID of a deleted branch whose erasure to retry",
|
|
243
243
|
"name": "name",
|
|
244
244
|
"required": true
|
|
245
245
|
}
|
|
@@ -908,5 +908,5 @@
|
|
|
908
908
|
]
|
|
909
909
|
}
|
|
910
910
|
},
|
|
911
|
-
"version": "0.
|
|
911
|
+
"version": "0.1.1"
|
|
912
912
|
}
|
package/openapi.json
CHANGED
|
@@ -980,7 +980,7 @@
|
|
|
980
980
|
"Branches"
|
|
981
981
|
],
|
|
982
982
|
"summary": "Create branch",
|
|
983
|
-
"description": "Requires environments.update in the connector organization. Uses
|
|
983
|
+
"description": "Requires environments.update in the connector organization and a recent applied source position, or returns 409 source_progress_unavailable. Uses a published mark covering that position or accepts a branch waiting for an on-demand mark. A wait that does not bind a covering mark within 30 minutes fails the workflow as snapshot_unavailable and erases the reservation. Caller-selected snapshots and other unknown fields are rejected. Names are unique within a connector while the branch is not deleted. One JSON object, at most 16 KiB. Poll operation.workflow_id; acceptance is not completion.",
|
|
984
984
|
"requestBody": {
|
|
985
985
|
"required": true,
|
|
986
986
|
"content": {
|
|
@@ -1662,6 +1662,19 @@
|
|
|
1662
1662
|
"updated_at": {
|
|
1663
1663
|
"type": "string",
|
|
1664
1664
|
"format": "date-time"
|
|
1665
|
+
},
|
|
1666
|
+
"target_source_lsn": {
|
|
1667
|
+
"type": "string",
|
|
1668
|
+
"description": "Writer-applied source LSN accepted as the minimum covered position for this branch request. The materialized mark can have a later cutpoint. Absent on older branches."
|
|
1669
|
+
},
|
|
1670
|
+
"target_source_lineage_id": {
|
|
1671
|
+
"type": "string",
|
|
1672
|
+
"description": "Source lineage to which target_source_lsn belongs."
|
|
1673
|
+
},
|
|
1674
|
+
"target_source_observed_at": {
|
|
1675
|
+
"type": "string",
|
|
1676
|
+
"format": "date-time",
|
|
1677
|
+
"description": "When the authenticated node reported target_source_lsn."
|
|
1665
1678
|
}
|
|
1666
1679
|
}
|
|
1667
1680
|
},
|
|
@@ -1901,7 +1914,7 @@
|
|
|
1901
1914
|
"enum": [
|
|
1902
1915
|
"seed_origin_v1"
|
|
1903
1916
|
],
|
|
1904
|
-
"description": "Optional birth ownership restoration at the
|
|
1917
|
+
"description": "Optional birth ownership restoration at a published mark covering the accepted source position. An explicit seed_origin_v1 request is refused when that position is beyond the seed origin or its declaration is unavailable. Other mark-dependent refusals may fail the accepted workflow before materialization. Credentials retain public_read_write_v1."
|
|
1905
1918
|
}
|
|
1906
1919
|
},
|
|
1907
1920
|
"additionalProperties": false
|