voratiq 0.1.0-beta.26 → 0.1.0-beta.27
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/dist/app-session/api-client.d.ts +42 -0
- package/dist/app-session/api-client.js +143 -0
- package/dist/app-session/authenticated-api.d.ts +31 -0
- package/dist/app-session/authenticated-api.js +143 -0
- package/dist/app-session/browser.d.ts +1 -0
- package/dist/app-session/browser.js +43 -0
- package/dist/app-session/callback.d.ts +19 -0
- package/dist/app-session/callback.js +126 -0
- package/dist/app-session/login.d.ts +27 -0
- package/dist/app-session/login.js +107 -0
- package/dist/app-session/repository-connections.d.ts +26 -0
- package/dist/app-session/repository-connections.js +32 -0
- package/dist/app-session/repository-link-sync.d.ts +11 -0
- package/dist/app-session/repository-link-sync.js +51 -0
- package/dist/app-session/session.d.ts +34 -0
- package/dist/app-session/session.js +32 -0
- package/dist/app-session/state-path.d.ts +2 -0
- package/dist/app-session/state-path.js +15 -0
- package/dist/app-session/state.d.ts +54 -0
- package/dist/app-session/state.js +336 -0
- package/dist/app-session/workflow-sessions.d.ts +14 -0
- package/dist/app-session/workflow-sessions.js +32 -0
- package/dist/app-session/workflow-upload.d.ts +93 -0
- package/dist/app-session/workflow-upload.js +473 -0
- package/dist/bin.js +32 -0
- package/dist/cli/apply.js +5 -0
- package/dist/cli/auto.js +3 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.js +20 -0
- package/dist/cli/message.js +2 -0
- package/dist/cli/reduce.js +2 -0
- package/dist/cli/repository-link.d.ts +23 -0
- package/dist/cli/repository-link.js +76 -0
- package/dist/cli/root-launcher.d.ts +13 -1
- package/dist/cli/root-launcher.js +19 -1
- package/dist/cli/run.js +40 -15
- package/dist/cli/spec.js +2 -0
- package/dist/cli/status.d.ts +46 -0
- package/dist/cli/status.js +179 -0
- package/dist/cli/verify.js +2 -0
- package/dist/commands/apply/command.js +1 -0
- package/dist/commands/root-launcher/command.d.ts +4 -0
- package/dist/commands/root-launcher/command.js +7 -1
- package/dist/domain/message/persistence/adapter.js +9 -0
- package/dist/domain/reduce/persistence/adapter.js +9 -0
- package/dist/domain/run/persistence/adapter.d.ts +2 -0
- package/dist/domain/run/persistence/adapter.js +17 -3
- package/dist/domain/shared/workflow-record-events.d.ts +39 -0
- package/dist/domain/shared/workflow-record-events.js +12 -0
- package/dist/domain/spec/persistence/adapter.js +9 -0
- package/dist/domain/verify/persistence/adapter.js +9 -0
- package/dist/persistence/session-store.d.ts +10 -0
- package/dist/persistence/session-store.js +56 -2
- package/dist/utils/git.d.ts +5 -0
- package/dist/utils/git.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AppSessionPayload } from "./session.js";
|
|
2
|
+
export declare const DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS = 60000;
|
|
3
|
+
export declare class AppApiError extends Error {
|
|
4
|
+
readonly code: string | null;
|
|
5
|
+
readonly statusCode: number;
|
|
6
|
+
constructor(message: string, code: string | null, statusCode: number);
|
|
7
|
+
}
|
|
8
|
+
export declare class AppSessionRefreshResponseError extends Error {
|
|
9
|
+
constructor(message: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export interface CreateCliLoginAttemptResult {
|
|
14
|
+
attemptId: string;
|
|
15
|
+
authorizeUrl: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RefreshCliSessionOptions {
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
now?: () => Date;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveVoratiqApiOrigin(env?: NodeJS.ProcessEnv): string;
|
|
25
|
+
export declare function resolveVoratiqAppOrigin(env?: NodeJS.ProcessEnv): string;
|
|
26
|
+
export declare function buildAppApiUrl(path: string, options?: {
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
}): URL;
|
|
29
|
+
export declare function readAppApiError(response: Response): Promise<AppApiError>;
|
|
30
|
+
export declare function isAbortError(error: unknown): boolean;
|
|
31
|
+
export declare function throwIfAppApiRequestAborted(signal?: AbortSignal): void;
|
|
32
|
+
export declare function createCliLoginAttempt(input: {
|
|
33
|
+
installationId: string;
|
|
34
|
+
callbackUrl: string;
|
|
35
|
+
callbackState: string;
|
|
36
|
+
}, env?: NodeJS.ProcessEnv): Promise<CreateCliLoginAttemptResult>;
|
|
37
|
+
export declare function exchangeCliLoginCode(input: {
|
|
38
|
+
code: string;
|
|
39
|
+
}, env?: NodeJS.ProcessEnv): Promise<AppSessionPayload>;
|
|
40
|
+
export declare function refreshCliSession(input: {
|
|
41
|
+
refreshToken: string;
|
|
42
|
+
}, options?: RefreshCliSessionOptions): Promise<AppSessionPayload>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { parseAppSessionPayloadFromUnknown } from "./state.js";
|
|
2
|
+
const DEFAULT_API_ORIGIN = "https://voratiq-api.fly.dev";
|
|
3
|
+
const DEFAULT_APP_ORIGIN = "https://voratiq.com";
|
|
4
|
+
export const DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS = 60_000;
|
|
5
|
+
export class AppApiError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
statusCode;
|
|
8
|
+
constructor(message, code, statusCode) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.name = "AppApiError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class AppSessionRefreshResponseError extends Error {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = "AppSessionRefreshResponseError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function resolveVoratiqApiOrigin(env = process.env) {
|
|
22
|
+
return env.VORATIQ_API_ORIGIN?.trim() || DEFAULT_API_ORIGIN;
|
|
23
|
+
}
|
|
24
|
+
export function resolveVoratiqAppOrigin(env = process.env) {
|
|
25
|
+
return env.VORATIQ_SITE_URL?.trim() || DEFAULT_APP_ORIGIN;
|
|
26
|
+
}
|
|
27
|
+
export function buildAppApiUrl(path, options = {}) {
|
|
28
|
+
const origin = resolveVoratiqApiOrigin(options.env ?? process.env);
|
|
29
|
+
return new URL(path, new URL("/api/v1/", origin));
|
|
30
|
+
}
|
|
31
|
+
export async function readAppApiError(response) {
|
|
32
|
+
try {
|
|
33
|
+
const payload = (await response.json());
|
|
34
|
+
return new AppApiError(payload.error?.message ?? `${response.status} ${response.statusText}`, payload.error?.code ?? null, response.status);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return new AppApiError(`${response.status} ${response.statusText}`, null, response.status);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isAbortError(error) {
|
|
41
|
+
return (hasErrorName(error) &&
|
|
42
|
+
(error.name === "AbortError" || error.name === "TimeoutError"));
|
|
43
|
+
}
|
|
44
|
+
export function throwIfAppApiRequestAborted(signal) {
|
|
45
|
+
if (!signal?.aborted) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
}
|
|
50
|
+
export async function createCliLoginAttempt(input, env = process.env) {
|
|
51
|
+
const response = await fetch(buildAppApiUrl("auth/cli/attempts", { env }), {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
accept: "application/json",
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
...input,
|
|
59
|
+
appOrigin: resolveVoratiqAppOrigin(env),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw await readAppApiError(response);
|
|
64
|
+
}
|
|
65
|
+
return (await response.json());
|
|
66
|
+
}
|
|
67
|
+
export async function exchangeCliLoginCode(input, env = process.env) {
|
|
68
|
+
const response = await fetch(buildAppApiUrl("auth/cli/exchange", { env }), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
accept: "application/json",
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(input),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw await readAppApiError(response);
|
|
78
|
+
}
|
|
79
|
+
const payload = (await response.json());
|
|
80
|
+
return parseAppSessionPayloadFromUnknown(payload, "Voratiq App exchange response");
|
|
81
|
+
}
|
|
82
|
+
export async function refreshCliSession(input, options = {}) {
|
|
83
|
+
const env = options.env ?? process.env;
|
|
84
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
85
|
+
const now = options.now ?? (() => new Date());
|
|
86
|
+
const response = await fetchImpl(buildAppApiUrl("auth/cli/refresh", {
|
|
87
|
+
env,
|
|
88
|
+
}), {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
accept: "application/json",
|
|
92
|
+
"content-type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
refreshToken: input.refreshToken,
|
|
96
|
+
}),
|
|
97
|
+
signal: options.signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw await readAppApiError(response);
|
|
101
|
+
}
|
|
102
|
+
let payload;
|
|
103
|
+
try {
|
|
104
|
+
payload = (await response.json());
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: expected JSON.", {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
let refreshedSession;
|
|
112
|
+
try {
|
|
113
|
+
refreshedSession = parseAppSessionPayloadFromUnknown(payload, "Voratiq App refresh response");
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: expected a complete CLI session payload.", {
|
|
117
|
+
cause: error,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
validateRefreshedSessionPayload(refreshedSession, now());
|
|
121
|
+
return refreshedSession;
|
|
122
|
+
}
|
|
123
|
+
function hasErrorName(error) {
|
|
124
|
+
return (typeof error === "object" &&
|
|
125
|
+
error !== null &&
|
|
126
|
+
"name" in error &&
|
|
127
|
+
typeof error.name === "string");
|
|
128
|
+
}
|
|
129
|
+
function validateRefreshedSessionPayload(payload, now) {
|
|
130
|
+
const nowMs = now.getTime();
|
|
131
|
+
const accessTokenExpiresAt = Date.parse(payload.session.accessTokenExpiresAt);
|
|
132
|
+
const refreshTokenExpiresAt = Date.parse(payload.session.refreshTokenExpiresAt);
|
|
133
|
+
if (!Number.isFinite(accessTokenExpiresAt) || accessTokenExpiresAt <= nowMs) {
|
|
134
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: access token expiry is unusable.");
|
|
135
|
+
}
|
|
136
|
+
if (!Number.isFinite(refreshTokenExpiresAt) ||
|
|
137
|
+
refreshTokenExpiresAt <= nowMs) {
|
|
138
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: refresh token expiry is unusable.");
|
|
139
|
+
}
|
|
140
|
+
if (refreshTokenExpiresAt <= accessTokenExpiresAt) {
|
|
141
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: refresh token expires too early.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { refreshCliSession } from "./api-client.js";
|
|
2
|
+
import type { AppSessionPayload } from "./session.js";
|
|
3
|
+
import { writeAppSessionState } from "./session.js";
|
|
4
|
+
import { readAppSessionState } from "./state.js";
|
|
5
|
+
export type AppSessionAuthErrorCode = "session_missing" | "session_expired" | "session_read_failed" | "session_write_failed" | "refresh_failed" | "invalid_refresh_response";
|
|
6
|
+
export declare class AppSessionAuthError extends Error {
|
|
7
|
+
readonly code: AppSessionAuthErrorCode;
|
|
8
|
+
constructor(message: string, code: AppSessionAuthErrorCode, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export interface AuthenticatedAppSessionRequestContext {
|
|
13
|
+
session: AppSessionPayload;
|
|
14
|
+
accessToken: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface RunWithAuthenticatedAppSessionOptions<Result> {
|
|
18
|
+
run: (context: AuthenticatedAppSessionRequestContext) => Promise<Result>;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
refreshWindowMs?: number;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
isAuthenticationFailure?: (error: unknown) => boolean;
|
|
23
|
+
}
|
|
24
|
+
interface RunWithAuthenticatedAppSessionDependencies {
|
|
25
|
+
readAppSessionState: typeof readAppSessionState;
|
|
26
|
+
writeAppSessionState: typeof writeAppSessionState;
|
|
27
|
+
refreshCliSession: typeof refreshCliSession;
|
|
28
|
+
now: () => Date;
|
|
29
|
+
}
|
|
30
|
+
export declare function runWithAuthenticatedAppSession<Result>(options: RunWithAuthenticatedAppSessionOptions<Result>, dependencies?: Partial<RunWithAuthenticatedAppSessionDependencies>): Promise<Result>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AppApiError, AppSessionRefreshResponseError, DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS, isAbortError, refreshCliSession, throwIfAppApiRequestAborted, } from "./api-client.js";
|
|
2
|
+
import { writeAppSessionState } from "./session.js";
|
|
3
|
+
import { readAppSessionState } from "./state.js";
|
|
4
|
+
export class AppSessionAuthError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code, options) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "AppSessionAuthError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function runWithAuthenticatedAppSession(options, dependencies = {}) {
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
const refreshWindowMs = normalizeRefreshWindowMs(options.refreshWindowMs);
|
|
15
|
+
const isAuthenticationFailure = options.isAuthenticationFailure ?? isAuthenticationFailureByDefault;
|
|
16
|
+
const deps = {
|
|
17
|
+
readAppSessionState,
|
|
18
|
+
writeAppSessionState,
|
|
19
|
+
refreshCliSession,
|
|
20
|
+
now: () => new Date(),
|
|
21
|
+
...dependencies,
|
|
22
|
+
};
|
|
23
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
24
|
+
let session = await readStoredSessionOrThrow({
|
|
25
|
+
env,
|
|
26
|
+
readAppSessionState: deps.readAppSessionState,
|
|
27
|
+
});
|
|
28
|
+
if (hasExpired(session.session.refreshTokenExpiresAt, deps.now())) {
|
|
29
|
+
throw buildSessionExpiredError();
|
|
30
|
+
}
|
|
31
|
+
let refreshedForRequest = false;
|
|
32
|
+
if (shouldRefreshAccessToken(session.session.accessTokenExpiresAt, deps.now(), refreshWindowMs)) {
|
|
33
|
+
session = await refreshAndPersistSession({
|
|
34
|
+
session,
|
|
35
|
+
env,
|
|
36
|
+
signal: options.signal,
|
|
37
|
+
dependencies: deps,
|
|
38
|
+
});
|
|
39
|
+
refreshedForRequest = true;
|
|
40
|
+
}
|
|
41
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
42
|
+
try {
|
|
43
|
+
return await options.run({
|
|
44
|
+
session,
|
|
45
|
+
accessToken: session.session.accessToken,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!isAuthenticationFailure(error) || refreshedForRequest) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (hasExpired(session.session.refreshTokenExpiresAt, deps.now())) {
|
|
54
|
+
throw buildSessionExpiredError();
|
|
55
|
+
}
|
|
56
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
57
|
+
session = await refreshAndPersistSession({
|
|
58
|
+
session,
|
|
59
|
+
env,
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
dependencies: deps,
|
|
62
|
+
});
|
|
63
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
64
|
+
return await options.run({
|
|
65
|
+
session,
|
|
66
|
+
accessToken: session.session.accessToken,
|
|
67
|
+
signal: options.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readStoredSessionOrThrow(options) {
|
|
72
|
+
try {
|
|
73
|
+
const state = await options.readAppSessionState(options.env);
|
|
74
|
+
if (!state.raw) {
|
|
75
|
+
throw new AppSessionAuthError("Voratiq App login is required. Run `voratiq login`.", "session_missing");
|
|
76
|
+
}
|
|
77
|
+
return state.raw;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof AppSessionAuthError) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
throw new AppSessionAuthError("Could not read saved Voratiq App sign-in state.", "session_read_failed", { cause: error });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function refreshAndPersistSession(options) {
|
|
87
|
+
if (hasExpired(options.session.session.refreshTokenExpiresAt, options.dependencies.now())) {
|
|
88
|
+
throw buildSessionExpiredError();
|
|
89
|
+
}
|
|
90
|
+
let refreshed;
|
|
91
|
+
try {
|
|
92
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
93
|
+
refreshed = await options.dependencies.refreshCliSession({
|
|
94
|
+
refreshToken: options.session.session.refreshToken,
|
|
95
|
+
}, {
|
|
96
|
+
env: options.env,
|
|
97
|
+
now: options.dependencies.now,
|
|
98
|
+
signal: options.signal,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (isAbortForSignal(error, options.signal)) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (error instanceof AppSessionRefreshResponseError) {
|
|
106
|
+
throw new AppSessionAuthError("Invalid Voratiq App session refresh response.", "invalid_refresh_response", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
throw new AppSessionAuthError("Voratiq App session refresh failed. Run `voratiq login` again.", "refresh_failed", { cause: error });
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await options.dependencies.writeAppSessionState(refreshed, options.env);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new AppSessionAuthError("Could not update saved Voratiq App sign-in state.", "session_write_failed", {
|
|
115
|
+
cause: error,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return refreshed;
|
|
119
|
+
}
|
|
120
|
+
function isAbortForSignal(error, signal) {
|
|
121
|
+
return (signal?.aborted === true || (signal !== undefined && isAbortError(error)));
|
|
122
|
+
}
|
|
123
|
+
function isAuthenticationFailureByDefault(error) {
|
|
124
|
+
return (error instanceof AppApiError &&
|
|
125
|
+
(error.statusCode === 401 || error.statusCode === 403));
|
|
126
|
+
}
|
|
127
|
+
function shouldRefreshAccessToken(accessTokenExpiresAt, now, refreshWindowMs) {
|
|
128
|
+
return Date.parse(accessTokenExpiresAt) - now.getTime() <= refreshWindowMs;
|
|
129
|
+
}
|
|
130
|
+
function hasExpired(timestamp, now) {
|
|
131
|
+
return Date.parse(timestamp) <= now.getTime();
|
|
132
|
+
}
|
|
133
|
+
function normalizeRefreshWindowMs(refreshWindowMs) {
|
|
134
|
+
if (typeof refreshWindowMs !== "number" ||
|
|
135
|
+
!Number.isFinite(refreshWindowMs) ||
|
|
136
|
+
refreshWindowMs < 0) {
|
|
137
|
+
return DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS;
|
|
138
|
+
}
|
|
139
|
+
return refreshWindowMs;
|
|
140
|
+
}
|
|
141
|
+
function buildSessionExpiredError() {
|
|
142
|
+
return new AppSessionAuthError("Voratiq App session expired. Run `voratiq login` again.", "session_expired");
|
|
143
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openExternalUrl(url: string): Promise<boolean>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
function resolveOpenCommand(url) {
|
|
3
|
+
switch (process.platform) {
|
|
4
|
+
case "darwin":
|
|
5
|
+
return { command: "open", args: [url] };
|
|
6
|
+
case "win32":
|
|
7
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
8
|
+
default:
|
|
9
|
+
return { command: "xdg-open", args: [url] };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function openExternalUrl(url) {
|
|
13
|
+
if (process.env.VORATIQ_DISABLE_BROWSER_OPEN === "1") {
|
|
14
|
+
return Promise.resolve(false);
|
|
15
|
+
}
|
|
16
|
+
const { command, args } = resolveOpenCommand(url);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
const settle = (opened) => {
|
|
20
|
+
if (settled) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
settled = true;
|
|
24
|
+
resolve(opened);
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: "ignore",
|
|
30
|
+
});
|
|
31
|
+
child.once("spawn", () => {
|
|
32
|
+
settle(true);
|
|
33
|
+
});
|
|
34
|
+
child.once("error", () => {
|
|
35
|
+
settle(false);
|
|
36
|
+
});
|
|
37
|
+
child.unref();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
settle(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class AppSignInCallbackError extends Error {
|
|
2
|
+
readonly code: "bind_failed" | "timed_out" | "state_mismatch" | "malformed_callback";
|
|
3
|
+
constructor(code: "bind_failed" | "timed_out" | "state_mismatch" | "malformed_callback", message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface AppSignInCallbackResult {
|
|
6
|
+
code: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AppSignInCallbackServer {
|
|
9
|
+
callbackUrl: string;
|
|
10
|
+
waitForResult(timeoutMs: number): Promise<AppSignInCallbackResult>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function startAppSignInCallbackServer(options: {
|
|
14
|
+
expectedState: string;
|
|
15
|
+
completionUrl: string;
|
|
16
|
+
host?: string;
|
|
17
|
+
pathname?: string;
|
|
18
|
+
port?: number;
|
|
19
|
+
}): Promise<AppSignInCallbackServer>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
export class AppSignInCallbackError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AppSignInCallbackError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function redirectResponse(response, location) {
|
|
11
|
+
response.statusCode = 303;
|
|
12
|
+
response.setHeader("connection", "close");
|
|
13
|
+
response.setHeader("location", location);
|
|
14
|
+
response.end();
|
|
15
|
+
}
|
|
16
|
+
function buildCompletionUrl(completionUrl, status, reason) {
|
|
17
|
+
const url = new URL(completionUrl);
|
|
18
|
+
if (status !== "success") {
|
|
19
|
+
url.searchParams.set("status", status);
|
|
20
|
+
}
|
|
21
|
+
if (reason) {
|
|
22
|
+
url.searchParams.set("reason", reason);
|
|
23
|
+
}
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
async function closeServer(server) {
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
server.close((error) => {
|
|
29
|
+
if (error) {
|
|
30
|
+
reject(error);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function startAppSignInCallbackServer(options) {
|
|
38
|
+
const host = options.host ?? "127.0.0.1";
|
|
39
|
+
const pathname = options.pathname ?? "/callback";
|
|
40
|
+
const port = options.port ?? 0;
|
|
41
|
+
let resolved = false;
|
|
42
|
+
let resolveResult = null;
|
|
43
|
+
let rejectResult = null;
|
|
44
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
45
|
+
resolveResult = resolve;
|
|
46
|
+
rejectResult = reject;
|
|
47
|
+
});
|
|
48
|
+
async function settleWithError(error) {
|
|
49
|
+
if (resolved) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
resolved = true;
|
|
53
|
+
await closeServer(server).catch(() => { });
|
|
54
|
+
rejectResult?.(error);
|
|
55
|
+
}
|
|
56
|
+
async function settleWithResult(result) {
|
|
57
|
+
if (resolved) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resolved = true;
|
|
61
|
+
await closeServer(server).catch(() => { });
|
|
62
|
+
resolveResult?.(result);
|
|
63
|
+
}
|
|
64
|
+
const server = createServer((request, response) => {
|
|
65
|
+
const url = new URL(request.url ?? pathname, `http://${host}`);
|
|
66
|
+
if (url.pathname !== pathname) {
|
|
67
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "not_found"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const state = url.searchParams.get("state");
|
|
71
|
+
const code = url.searchParams.get("code");
|
|
72
|
+
if (state !== options.expectedState) {
|
|
73
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "state_mismatch"));
|
|
74
|
+
void settleWithError(new AppSignInCallbackError("state_mismatch", "The browser callback state did not match the active login request."));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!code) {
|
|
78
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "malformed_callback"));
|
|
79
|
+
void settleWithError(new AppSignInCallbackError("malformed_callback", "The browser callback did not include a usable exchange code."));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "success"));
|
|
83
|
+
void settleWithResult({ code });
|
|
84
|
+
});
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
server.once("error", (error) => {
|
|
87
|
+
reject(new AppSignInCallbackError("bind_failed", error instanceof Error
|
|
88
|
+
? error.message
|
|
89
|
+
: "Failed to bind the localhost callback server."));
|
|
90
|
+
});
|
|
91
|
+
server.listen(port, host, () => {
|
|
92
|
+
server.removeAllListeners("error");
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const address = server.address();
|
|
97
|
+
if (!address || typeof address === "string") {
|
|
98
|
+
await closeServer(server);
|
|
99
|
+
throw new AppSignInCallbackError("bind_failed", "Failed to resolve the localhost callback address.");
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
callbackUrl: `http://${host}:${address.port}${pathname}`,
|
|
103
|
+
async waitForResult(timeoutMs) {
|
|
104
|
+
let timeoutHandle;
|
|
105
|
+
try {
|
|
106
|
+
return await Promise.race([
|
|
107
|
+
resultPromise,
|
|
108
|
+
new Promise((_, reject) => {
|
|
109
|
+
timeoutHandle = setTimeout(() => {
|
|
110
|
+
reject(new AppSignInCallbackError("timed_out", "Timed out waiting for the browser to finish login approval."));
|
|
111
|
+
void closeServer(server).catch(() => { });
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (timeoutHandle) {
|
|
118
|
+
clearTimeout(timeoutHandle);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async close() {
|
|
123
|
+
await closeServer(server);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CommandOutputWriter } from "../cli/output.js";
|
|
3
|
+
import { createCliLoginAttempt, exchangeCliLoginCode } from "./api-client.js";
|
|
4
|
+
import { openExternalUrl } from "./browser.js";
|
|
5
|
+
import { startAppSignInCallbackServer } from "./callback.js";
|
|
6
|
+
import { type AppSessionPayload, writeAppSessionState } from "./session.js";
|
|
7
|
+
import { readAppSessionState } from "./state.js";
|
|
8
|
+
export interface AppSignInDependencies {
|
|
9
|
+
createLoginAttempt: typeof createCliLoginAttempt;
|
|
10
|
+
exchangeLoginCode: typeof exchangeCliLoginCode;
|
|
11
|
+
openExternalUrl: typeof openExternalUrl;
|
|
12
|
+
readAppSessionState: typeof readAppSessionState;
|
|
13
|
+
startCallbackServer: typeof startAppSignInCallbackServer;
|
|
14
|
+
writeAppSessionState: typeof writeAppSessionState;
|
|
15
|
+
randomUUID: typeof randomUUID;
|
|
16
|
+
now: () => Date;
|
|
17
|
+
}
|
|
18
|
+
export interface AppSignInResult {
|
|
19
|
+
body: string;
|
|
20
|
+
statePath: string;
|
|
21
|
+
session: AppSessionPayload;
|
|
22
|
+
}
|
|
23
|
+
export declare function performAppSignIn(options?: {
|
|
24
|
+
writeOutput?: CommandOutputWriter;
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}, dependencies?: Partial<AppSignInDependencies>): Promise<AppSignInResult>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CliError } from "../cli/errors.js";
|
|
3
|
+
import { colorize } from "../utils/colors.js";
|
|
4
|
+
import { AppApiError, createCliLoginAttempt, exchangeCliLoginCode, resolveVoratiqAppOrigin, } from "./api-client.js";
|
|
5
|
+
import { openExternalUrl } from "./browser.js";
|
|
6
|
+
import { AppSignInCallbackError, startAppSignInCallbackServer, } from "./callback.js";
|
|
7
|
+
import { writeAppSessionState } from "./session.js";
|
|
8
|
+
import { readAppSessionState } from "./state.js";
|
|
9
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
const COMPLETE_PATH = "/auth/cli/complete";
|
|
11
|
+
function emitInfo(writeOutput, message, options = {}) {
|
|
12
|
+
writeOutput?.({
|
|
13
|
+
body: message,
|
|
14
|
+
formatBody: {
|
|
15
|
+
leadingNewline: options.leadingNewline ?? false,
|
|
16
|
+
trailingNewline: false,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function toLoginCliError(error) {
|
|
21
|
+
if (error instanceof AppSignInCallbackError) {
|
|
22
|
+
switch (error.code) {
|
|
23
|
+
case "bind_failed":
|
|
24
|
+
return new CliError("Could not start the local sign-in callback.", [], [
|
|
25
|
+
"Allow loopback (127.0.0.1) binding for this shell, then run `voratiq login` again.",
|
|
26
|
+
]);
|
|
27
|
+
case "timed_out":
|
|
28
|
+
return new CliError("Timed out waiting for approval.", [], ["Run `voratiq login` again to retry."]);
|
|
29
|
+
case "state_mismatch":
|
|
30
|
+
case "malformed_callback":
|
|
31
|
+
return new CliError("The sign-in callback was invalid.", [], ["Close the browser tab and run `voratiq login` again."]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof AppApiError) {
|
|
35
|
+
if (error.code === "cancelled_exchange") {
|
|
36
|
+
return new CliError("Sign-in was cancelled in the browser.", [], ["Run `voratiq login` again when you're ready."]);
|
|
37
|
+
}
|
|
38
|
+
return new CliError("Could not complete sign-in.", [], ["Check your Voratiq app and API configuration, then try again."]);
|
|
39
|
+
}
|
|
40
|
+
return error instanceof CliError
|
|
41
|
+
? error
|
|
42
|
+
: new CliError(error instanceof Error ? error.message : "Login failed.");
|
|
43
|
+
}
|
|
44
|
+
function renderLoginSuccess(session) {
|
|
45
|
+
const signedInAs = session.actor.email ?? session.actor.id;
|
|
46
|
+
return `${colorize("Success:", "green")} Signed in to Voratiq App as ${signedInAs}`;
|
|
47
|
+
}
|
|
48
|
+
export async function performAppSignIn(options = {}, dependencies = {}) {
|
|
49
|
+
const env = options.env ?? process.env;
|
|
50
|
+
const writeOutput = options.writeOutput;
|
|
51
|
+
const deps = {
|
|
52
|
+
createLoginAttempt: createCliLoginAttempt,
|
|
53
|
+
exchangeLoginCode: exchangeCliLoginCode,
|
|
54
|
+
openExternalUrl,
|
|
55
|
+
readAppSessionState,
|
|
56
|
+
startCallbackServer: startAppSignInCallbackServer,
|
|
57
|
+
writeAppSessionState,
|
|
58
|
+
randomUUID,
|
|
59
|
+
now: () => new Date(),
|
|
60
|
+
...dependencies,
|
|
61
|
+
};
|
|
62
|
+
const existingState = await deps.readAppSessionState(env);
|
|
63
|
+
const existingInstallationId = typeof existingState.raw?.installation.id === "string"
|
|
64
|
+
? existingState.raw.installation.id
|
|
65
|
+
: null;
|
|
66
|
+
const installationId = existingInstallationId ?? deps.randomUUID();
|
|
67
|
+
const callbackState = deps.randomUUID();
|
|
68
|
+
let callbackServer = null;
|
|
69
|
+
try {
|
|
70
|
+
callbackServer = await deps.startCallbackServer({
|
|
71
|
+
expectedState: callbackState,
|
|
72
|
+
completionUrl: new URL(COMPLETE_PATH, resolveVoratiqAppOrigin(env)).toString(),
|
|
73
|
+
});
|
|
74
|
+
const attempt = await deps.createLoginAttempt({
|
|
75
|
+
installationId,
|
|
76
|
+
callbackUrl: callbackServer.callbackUrl,
|
|
77
|
+
callbackState,
|
|
78
|
+
}, env);
|
|
79
|
+
emitInfo(writeOutput, "Opening browser to sign in...", {
|
|
80
|
+
leadingNewline: true,
|
|
81
|
+
});
|
|
82
|
+
const opened = await deps.openExternalUrl(attempt.authorizeUrl);
|
|
83
|
+
if (!opened) {
|
|
84
|
+
emitInfo(writeOutput, "Browser didn't open. Open this URL to continue:");
|
|
85
|
+
emitInfo(writeOutput, attempt.authorizeUrl);
|
|
86
|
+
}
|
|
87
|
+
emitInfo(writeOutput, "Waiting for approval in your browser...", {
|
|
88
|
+
leadingNewline: !opened,
|
|
89
|
+
});
|
|
90
|
+
const callback = await callbackServer.waitForResult(options.timeoutMs ?? LOGIN_TIMEOUT_MS);
|
|
91
|
+
const session = await deps.exchangeLoginCode({ code: callback.code }, env);
|
|
92
|
+
const statePath = await deps.writeAppSessionState(session, env);
|
|
93
|
+
return {
|
|
94
|
+
body: renderLoginSuccess(session),
|
|
95
|
+
statePath,
|
|
96
|
+
session,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw toLoginCliError(error);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (callbackServer) {
|
|
104
|
+
await callbackServer.close().catch(() => { });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|