@vizamodo/modo-dispatcher 1.1.77
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 +43 -0
- package/dist/artifacts/builders.d.ts +32 -0
- package/dist/artifacts/builders.js +72 -0
- package/dist/artifacts/downloader.d.ts +28 -0
- package/dist/artifacts/downloader.js +102 -0
- package/dist/artifacts/normalizer.d.ts +22 -0
- package/dist/artifacts/normalizer.js +65 -0
- package/dist/artifacts/types.d.ts +94 -0
- package/dist/artifacts/types.js +1 -0
- package/dist/auth/identity/extract-identity.d.ts +8 -0
- package/dist/auth/identity/extract-identity.js +15 -0
- package/dist/auth/identity/normalize-teams.d.ts +1 -0
- package/dist/auth/identity/normalize-teams.js +5 -0
- package/dist/auth/identity/validate-claims.d.ts +2 -0
- package/dist/auth/identity/validate-claims.js +5 -0
- package/dist/auth/jwt/parse.d.ts +2 -0
- package/dist/auth/jwt/parse.js +16 -0
- package/dist/auth/jwt/verify.d.ts +1 -0
- package/dist/auth/jwt/verify.js +9 -0
- package/dist/auth/oauth/auth-error.d.ts +12 -0
- package/dist/auth/oauth/auth-error.js +47 -0
- package/dist/auth/oauth/auth-session-store.d.ts +30 -0
- package/dist/auth/oauth/auth-session-store.js +46 -0
- package/dist/auth/oauth/callback-server.d.ts +21 -0
- package/dist/auth/oauth/callback-server.js +319 -0
- package/dist/auth/oauth/github.d.ts +14 -0
- package/dist/auth/oauth/github.js +100 -0
- package/dist/auth/oauth/sse.d.ts +11 -0
- package/dist/auth/oauth/sse.js +67 -0
- package/dist/auth/oauth/templates/failed.html +629 -0
- package/dist/auth/oauth/templates/pending.html +620 -0
- package/dist/auth/oauth/templates/success.html +577 -0
- package/dist/auth/session/access-token.d.ts +15 -0
- package/dist/auth/session/access-token.js +64 -0
- package/dist/auth/session/login.d.ts +18 -0
- package/dist/auth/session/login.js +144 -0
- package/dist/auth/session/logout.d.ts +3 -0
- package/dist/auth/session/logout.js +21 -0
- package/dist/auth/session/refresh-token.d.ts +8 -0
- package/dist/auth/session/refresh-token.js +16 -0
- package/dist/auth/session/refresh.d.ts +2 -0
- package/dist/auth/session/refresh.js +61 -0
- package/dist/auth/session/rotate.d.ts +4 -0
- package/dist/auth/session/rotate.js +4 -0
- package/dist/auth/session/session-manager.d.ts +16 -0
- package/dist/auth/session/session-manager.js +54 -0
- package/dist/auth/session/token-state.d.ts +20 -0
- package/dist/auth/session/token-state.js +55 -0
- package/dist/auth/session/types.d.ts +35 -0
- package/dist/auth/session/types.js +1 -0
- package/dist/auth/storage/keychain.d.ts +16 -0
- package/dist/auth/storage/keychain.js +107 -0
- package/dist/auth/storage/memory.d.ts +10 -0
- package/dist/auth/storage/memory.js +15 -0
- package/dist/auth/storage/types.d.ts +5 -0
- package/dist/auth/storage/types.js +1 -0
- package/dist/config/defaults.d.ts +94 -0
- package/dist/config/defaults.js +116 -0
- package/dist/core/auth-bootstrap.d.ts +15 -0
- package/dist/core/auth-bootstrap.js +33 -0
- package/dist/core/bootstrap.d.ts +73 -0
- package/dist/core/bootstrap.js +248 -0
- package/dist/core/dispatcher.d.ts +2 -0
- package/dist/core/dispatcher.js +28 -0
- package/dist/core/ensure-bootstrap.d.ts +3 -0
- package/dist/core/ensure-bootstrap.js +25 -0
- package/dist/core/errors.d.ts +69 -0
- package/dist/core/errors.js +121 -0
- package/dist/core/runtime-orchestrator.d.ts +17 -0
- package/dist/core/runtime-orchestrator.js +47 -0
- package/dist/core/runtime.d.ts +16 -0
- package/dist/core/runtime.js +52 -0
- package/dist/core/validation.d.ts +2 -0
- package/dist/core/validation.js +21 -0
- package/dist/crypto/encrypt.d.ts +12 -0
- package/dist/crypto/encrypt.js +80 -0
- package/dist/flows/email-otp-flow.d.ts +7 -0
- package/dist/flows/email-otp-flow.js +249 -0
- package/dist/gateway/auth-header.d.ts +2 -0
- package/dist/gateway/auth-header.js +21 -0
- package/dist/gateway/client.d.ts +7 -0
- package/dist/gateway/client.js +24 -0
- package/dist/gateway/dispatch-with-bootstrap-retry.d.ts +25 -0
- package/dist/gateway/dispatch-with-bootstrap-retry.js +157 -0
- package/dist/gateway/dispatch.d.ts +23 -0
- package/dist/gateway/dispatch.js +110 -0
- package/dist/gateway/session-waiter.d.ts +11 -0
- package/dist/gateway/session-waiter.js +126 -0
- package/dist/gateway/session.d.ts +21 -0
- package/dist/gateway/session.js +74 -0
- package/dist/gateway/types.d.ts +113 -0
- package/dist/gateway/types.js +19 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +21 -0
- package/dist/runtime/runtime-context.d.ts +6 -0
- package/dist/runtime/runtime-context.js +1 -0
- package/dist/types/dispatcher.d.ts +275 -0
- package/dist/types/dispatcher.js +1 -0
- package/dist/types/payload.d.ts +5 -0
- package/dist/types/payload.js +2 -0
- package/dist/types/runtime-env.d.ts +2 -0
- package/dist/types/runtime-env.js +5 -0
- package/dist/ui/banner.d.ts +4 -0
- package/dist/ui/banner.js +20 -0
- package/dist/utils/request-dedup.d.ts +75 -0
- package/dist/utils/request-dedup.js +102 -0
- package/dist/utils/type-guards.d.ts +173 -0
- package/dist/utils/type-guards.js +232 -0
- package/package.json +43 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import { safeDownloadResultArtifact } from "../artifacts/downloader.js";
|
|
3
|
+
import { isObject, hasDownloadUrl } from "../utils/type-guards.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
/**
|
|
6
|
+
* Runtime results from the gateway may be delivered either:
|
|
7
|
+
* a) inline JSON in `data.result`, or
|
|
8
|
+
* b) as an artifact reference `{ downloadUrl, path }` that must be
|
|
9
|
+
* downloaded and parsed.
|
|
10
|
+
*
|
|
11
|
+
* This helper handles both shapes transparently.
|
|
12
|
+
*/
|
|
13
|
+
async function resolveRuntimeResult(result) {
|
|
14
|
+
if (result.kind !== "runtime") {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const raw = result.data?.result;
|
|
18
|
+
if (raw == null) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
// Case (b): artifact reference — download and parse JSON
|
|
22
|
+
if (isObject(raw) && !Array.isArray(raw) && hasDownloadUrl(raw)) {
|
|
23
|
+
const artifact = {
|
|
24
|
+
path: 'path' in raw && typeof raw.path === 'string' ? raw.path : '',
|
|
25
|
+
downloadUrl: raw.downloadUrl,
|
|
26
|
+
contentType: 'contentType' in raw && typeof raw.contentType === 'string' ? raw.contentType : 'application/json',
|
|
27
|
+
};
|
|
28
|
+
const buffer = await safeDownloadResultArtifact({ result: artifact });
|
|
29
|
+
if (!buffer) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(buffer.toString("utf8"));
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Case (a): inline JSON
|
|
41
|
+
const inline = raw;
|
|
42
|
+
return inline;
|
|
43
|
+
}
|
|
44
|
+
function prompt(question) {
|
|
45
|
+
const rl = readline.createInterface({
|
|
46
|
+
input: process.stdin,
|
|
47
|
+
output: process.stdout,
|
|
48
|
+
});
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
rl.on("SIGINT", () => {
|
|
51
|
+
rl.close();
|
|
52
|
+
resolve(null);
|
|
53
|
+
});
|
|
54
|
+
rl.question(question, (answer) => {
|
|
55
|
+
rl.close();
|
|
56
|
+
resolve(answer.trim());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function runEmailOtpFlow(input, options, hooks, dispatcherCall) {
|
|
61
|
+
if (!dispatcherCall) {
|
|
62
|
+
throw new Error("Missing dispatcher call implementation for email OTP flow");
|
|
63
|
+
}
|
|
64
|
+
const flowOptions = {
|
|
65
|
+
...options,
|
|
66
|
+
interactiveAuthFlow: false,
|
|
67
|
+
banner: false, // suppress dispatch banner for inner verify/resend calls
|
|
68
|
+
};
|
|
69
|
+
// STEP 1: send OTP
|
|
70
|
+
const res1 = await dispatcherCall({
|
|
71
|
+
...input,
|
|
72
|
+
commandType: "email.provision"
|
|
73
|
+
}, flowOptions, "dispatch");
|
|
74
|
+
const result1 = await res1.wait();
|
|
75
|
+
if (result1.kind === "github") {
|
|
76
|
+
throw new Error(`Unexpected github execution: ${result1.conclusion}`);
|
|
77
|
+
}
|
|
78
|
+
const provisionResult = await resolveRuntimeResult(result1);
|
|
79
|
+
if (result1.status === "provisioned") {
|
|
80
|
+
const alias = provisionResult?.alias;
|
|
81
|
+
console.log("\n✅ Email already provisioned");
|
|
82
|
+
if (alias) {
|
|
83
|
+
console.log(chalk.gray(`\n📨 Your email ${alias} is ready to use.`));
|
|
84
|
+
}
|
|
85
|
+
return result1;
|
|
86
|
+
}
|
|
87
|
+
if (result1.status !== "sent") {
|
|
88
|
+
throw new Error(`Unexpected state: ${result1.status}`);
|
|
89
|
+
}
|
|
90
|
+
const fullPayload = input.payload;
|
|
91
|
+
const email = provisionResult?.email ?? fullPayload?.email;
|
|
92
|
+
const fullName = provisionResult?.fullName ?? fullPayload?.fullName;
|
|
93
|
+
if (!email) {
|
|
94
|
+
throw new Error("Missing email in payload or response");
|
|
95
|
+
}
|
|
96
|
+
if (!fullName) {
|
|
97
|
+
throw new Error("Missing fullName in payload or response");
|
|
98
|
+
}
|
|
99
|
+
const provisionContext = Object.freeze({
|
|
100
|
+
...input,
|
|
101
|
+
payload: Object.freeze({
|
|
102
|
+
...input.payload,
|
|
103
|
+
email,
|
|
104
|
+
fullName,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
const isNew = provisionResult?.isNew;
|
|
108
|
+
if (isNew === false) {
|
|
109
|
+
console.log(chalk.yellow("\n🔁 OTP already sent.\n" +
|
|
110
|
+
"Please check your email from Viza Development Platform and use the existing code below."));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log(chalk.yellow("\n📨 OTP sent successfully.\n" +
|
|
114
|
+
"Please check your email (from Viza Development Platform), copy the verification code,\n" +
|
|
115
|
+
"and enter it below to continue."));
|
|
116
|
+
}
|
|
117
|
+
// Highlight input section
|
|
118
|
+
console.log(chalk.magenta("\n─────────────────────── 🔐 ENTER OTP BELOW ──────────────────────────────────────────────────────────────────────────────────────\n"));
|
|
119
|
+
// STEP 2: verify loop
|
|
120
|
+
let lastResendAt = Date.now();
|
|
121
|
+
const RESEND_COOLDOWN_MS = 30000; // 30 seconds client-side throttle (reduce spam, better UX)
|
|
122
|
+
let nextAllowedVerifyAt = 0;
|
|
123
|
+
while (true) {
|
|
124
|
+
hooks?.onBeforePrompt?.();
|
|
125
|
+
const otp = await prompt("Enter OTP (or 'r' to resend): ");
|
|
126
|
+
hooks?.onAfterPrompt?.();
|
|
127
|
+
if (otp === null) {
|
|
128
|
+
console.log("\n👋 Operation cancelled.");
|
|
129
|
+
return {
|
|
130
|
+
kind: "cancel",
|
|
131
|
+
status: "cancelled",
|
|
132
|
+
sessionId: "interactive-email-otp",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// 🔁 resend OTP (client-side throttle, NOT Cloudflare resend)
|
|
136
|
+
if (otp.toLowerCase() === "r") {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
if (now - lastResendAt < RESEND_COOLDOWN_MS) {
|
|
139
|
+
const wait = Math.ceil((RESEND_COOLDOWN_MS - (now - lastResendAt)) / 1000);
|
|
140
|
+
hooks?.onBeforePrompt?.();
|
|
141
|
+
console.log(chalk.gray(`⏳ Please wait`), chalk.red(`${wait}s`), chalk.gray(`before resending OTP.`));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
lastResendAt = now;
|
|
145
|
+
const resendHandle = await dispatcherCall({
|
|
146
|
+
...provisionContext,
|
|
147
|
+
commandType: "email.provision",
|
|
148
|
+
payload: {
|
|
149
|
+
email,
|
|
150
|
+
fullName,
|
|
151
|
+
resendOtp: true
|
|
152
|
+
}
|
|
153
|
+
}, flowOptions, "dispatch");
|
|
154
|
+
const resendResult = await resendHandle.wait();
|
|
155
|
+
if (resendResult.kind === "github") {
|
|
156
|
+
throw new Error(`Unexpected github execution: ${resendResult.conclusion}`);
|
|
157
|
+
}
|
|
158
|
+
if (resendResult.kind === "cancel") {
|
|
159
|
+
throw new Error(`Resend cancelled: ${resendResult.status}`);
|
|
160
|
+
}
|
|
161
|
+
if (resendResult.status !== "sent" && resendResult.status !== "pending") {
|
|
162
|
+
throw new Error(`Resend failed: ${resendResult.status}`);
|
|
163
|
+
}
|
|
164
|
+
hooks?.onBeforePrompt?.();
|
|
165
|
+
console.log(chalk.gray("\n📩 OTP resent. Please check your inbox."));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// 🔒 Fail-fast: OTP must be exactly 6 digits (do not call server)
|
|
169
|
+
if (!/^\d{6}$/.test(otp)) {
|
|
170
|
+
hooks?.onBeforePrompt?.(); // stop spinner before printing message
|
|
171
|
+
console.log(chalk.gray("❌ OTP must be exactly 6 digits"));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
if (now < nextAllowedVerifyAt) {
|
|
176
|
+
const wait = Math.ceil((nextAllowedVerifyAt - now) / 1000);
|
|
177
|
+
hooks?.onBeforePrompt?.();
|
|
178
|
+
console.log(chalk.gray(`⏳ Please wait`), chalk.red(`${wait}s`), chalk.gray(`before trying again.`));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const res2 = await dispatcherCall({
|
|
182
|
+
...provisionContext,
|
|
183
|
+
commandType: "identity.verify",
|
|
184
|
+
payload: {
|
|
185
|
+
email,
|
|
186
|
+
fullName,
|
|
187
|
+
otp
|
|
188
|
+
}
|
|
189
|
+
}, flowOptions, "dispatch");
|
|
190
|
+
const result2 = await res2.wait();
|
|
191
|
+
if (result2.kind === "github") {
|
|
192
|
+
throw new Error(`Unexpected github execution: ${result2.conclusion}`);
|
|
193
|
+
}
|
|
194
|
+
const verifyResult = await resolveRuntimeResult(result2);
|
|
195
|
+
// SUCCESS (OTP verified + provision completed)
|
|
196
|
+
if (result2.status === "provisioned") {
|
|
197
|
+
const alias = verifyResult?.alias;
|
|
198
|
+
console.log("\n✅ Email provisioned");
|
|
199
|
+
console.log(chalk.gray("\n📬 Please check your email for the next onboarding steps to complete your Viza setup."));
|
|
200
|
+
if (alias) {
|
|
201
|
+
console.log(chalk.gray(`\n📨 Your email account ${alias} has been created.\n` +
|
|
202
|
+
`Please follow the instructions in your email to complete GitHub account setup before accessing Viza.\n`));
|
|
203
|
+
}
|
|
204
|
+
return result2;
|
|
205
|
+
}
|
|
206
|
+
// RETRY (cooldown or invalid)
|
|
207
|
+
if (result2.status === "pending") {
|
|
208
|
+
hooks?.onBeforePrompt?.();
|
|
209
|
+
const retryAfter = verifyResult?.retryAfter;
|
|
210
|
+
const attempts = verifyResult?.attempts;
|
|
211
|
+
const attemptsLeftFromServer = verifyResult?.attemptsLeft;
|
|
212
|
+
const maxAttempts = verifyResult?.maxAttempts;
|
|
213
|
+
const attemptsLeft = typeof attemptsLeftFromServer === "number"
|
|
214
|
+
? attemptsLeftFromServer
|
|
215
|
+
: (typeof attempts === "number" && typeof maxAttempts === "number")
|
|
216
|
+
? Math.max(0, maxAttempts - attempts)
|
|
217
|
+
: undefined;
|
|
218
|
+
if (typeof retryAfter === "number" && retryAfter > 0) {
|
|
219
|
+
nextAllowedVerifyAt = Date.now() + retryAfter;
|
|
220
|
+
console.log(chalk.gray("❌ Invalid OTP, please try again"));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(chalk.gray("❌ Invalid OTP, please try again"));
|
|
224
|
+
}
|
|
225
|
+
if (typeof attemptsLeft === "number") {
|
|
226
|
+
if (attemptsLeft <= 0) {
|
|
227
|
+
console.log(chalk.red(`🚫 No attempts remaining. Please request a new OTP.`));
|
|
228
|
+
return {
|
|
229
|
+
kind: "cancel",
|
|
230
|
+
status: "cancelled",
|
|
231
|
+
sessionId: "interactive-email-otp",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
else if (attemptsLeft === 1) {
|
|
235
|
+
console.log(chalk.red(`⚠️ Last attempt remaining! (1 left)`));
|
|
236
|
+
}
|
|
237
|
+
else if (attemptsLeft <= 3) {
|
|
238
|
+
console.log(chalk.yellow(`⚠️ Only ${attemptsLeft} attempts left`));
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
console.log(chalk.gray(`Attempts left: ${attemptsLeft}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// HARD FAIL
|
|
247
|
+
throw new Error(`Verification failed: ${result2.status}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAuthRuntime } from "../core/runtime.js";
|
|
2
|
+
export async function buildAuthHeader() {
|
|
3
|
+
const token = await getAuthRuntime().tokenState.getValidAccessToken();
|
|
4
|
+
return {
|
|
5
|
+
Authorization: `Bearer ${token}`,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export async function withAuthRetry(run, shouldRefresh) {
|
|
9
|
+
const firstHeaders = await buildAuthHeader();
|
|
10
|
+
const first = await run(firstHeaders);
|
|
11
|
+
const retryDecision = shouldRefresh(first);
|
|
12
|
+
if (!retryDecision) {
|
|
13
|
+
return first;
|
|
14
|
+
}
|
|
15
|
+
const runtime = getAuthRuntime();
|
|
16
|
+
runtime.tokenState.forceExpireAccessToken();
|
|
17
|
+
await runtime.tokenState.refreshAccessToken();
|
|
18
|
+
const retryHeaders = await buildAuthHeader();
|
|
19
|
+
const retryResult = await run(retryHeaders);
|
|
20
|
+
return retryResult;
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { withAuthRetry } from "./auth-header.js";
|
|
2
|
+
export async function gatewayFetch(request) {
|
|
3
|
+
return withAuthRetry(async (auth) => {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const timeout = setTimeout(() => controller.abort(), request.timeoutMs ?? 15_000);
|
|
6
|
+
try {
|
|
7
|
+
const init = {
|
|
8
|
+
method: request.method,
|
|
9
|
+
headers: {
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
...auth,
|
|
12
|
+
},
|
|
13
|
+
signal: controller.signal,
|
|
14
|
+
};
|
|
15
|
+
if (request.body !== undefined) {
|
|
16
|
+
init.body = JSON.stringify(request.body);
|
|
17
|
+
}
|
|
18
|
+
return await fetch(request.url, init);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
}
|
|
23
|
+
}, (res) => res.status === 401 || res.status === 403);
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DispatchInput, DispatchMode } from "../types/dispatcher.js";
|
|
2
|
+
import { GatewayBootstrapConfig } from "../core/bootstrap.js";
|
|
3
|
+
import { RuntimeEnv } from "../types/runtime-env.js";
|
|
4
|
+
import { GatewayDispatchAccepted } from "./types.js";
|
|
5
|
+
export interface DispatchWithBootstrapRetryInput {
|
|
6
|
+
input: DispatchInput;
|
|
7
|
+
mode: DispatchMode;
|
|
8
|
+
teams: string[];
|
|
9
|
+
bootstrap: GatewayBootstrapConfig;
|
|
10
|
+
targetEnv: RuntimeEnv;
|
|
11
|
+
}
|
|
12
|
+
export interface DispatchWithBootstrapRetryResult {
|
|
13
|
+
accepted: GatewayDispatchAccepted;
|
|
14
|
+
bootstrap: GatewayBootstrapConfig;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Attempt a dispatch with automatic bootstrap retry on encryption/decrypt failures.
|
|
18
|
+
*
|
|
19
|
+
* When the gateway rejects due to stale dispatch key or rotated runtime config,
|
|
20
|
+
* this function will:
|
|
21
|
+
* 1. Log why rebootstrap is triggered
|
|
22
|
+
* 2. Refresh bootstrap from the authenticated gateway (endpoint derived internally)
|
|
23
|
+
* 3. Retry the dispatch with fresh config
|
|
24
|
+
*/
|
|
25
|
+
export declare function dispatchWithBootstrapRetry(args: DispatchWithBootstrapRetryInput): Promise<DispatchWithBootstrapRetryResult>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { refreshBootstrapFromGateway } from "../core/bootstrap.js";
|
|
2
|
+
import { dispatchToGateway } from "./dispatch.js";
|
|
3
|
+
import { encryptPayload } from "../crypto/encrypt.js";
|
|
4
|
+
import { DispatchRejectedError } from "../core/errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Attempt a dispatch with automatic bootstrap retry on encryption/decrypt failures.
|
|
7
|
+
*
|
|
8
|
+
* When the gateway rejects due to stale dispatch key or rotated runtime config,
|
|
9
|
+
* this function will:
|
|
10
|
+
* 1. Log why rebootstrap is triggered
|
|
11
|
+
* 2. Refresh bootstrap from the authenticated gateway (endpoint derived internally)
|
|
12
|
+
* 3. Retry the dispatch with fresh config
|
|
13
|
+
*/
|
|
14
|
+
export async function dispatchWithBootstrapRetry(args) {
|
|
15
|
+
let bootstrapCfg = args.bootstrap;
|
|
16
|
+
let accepted;
|
|
17
|
+
try {
|
|
18
|
+
accepted = await attemptDispatch(args.input, bootstrapCfg, args.teams, args.mode, args.targetEnv);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const rawMsg = String(err?.message || "").toLowerCase();
|
|
22
|
+
const retryable = rawMsg.includes("fetch failed") ||
|
|
23
|
+
rawMsg.includes("failed to reach gateway") ||
|
|
24
|
+
rawMsg.includes("enotfound") ||
|
|
25
|
+
rawMsg.includes("econn") ||
|
|
26
|
+
rawMsg.includes("networkerror") ||
|
|
27
|
+
rawMsg.includes("bootstrap_encryption_failed");
|
|
28
|
+
const hint = extractGatewayErrorHint(err);
|
|
29
|
+
const decryptReject = isDecryptReject(err);
|
|
30
|
+
const bootstrapHint = isBootstrapDecryptHint(hint.hintText);
|
|
31
|
+
const needsBootstrap = bootstrapHint || decryptReject;
|
|
32
|
+
if (!retryable && !needsBootstrap) {
|
|
33
|
+
throw new DispatchRejectedError(`Dispatch request rejected by gateway${hint.detail}`, { cause: err });
|
|
34
|
+
}
|
|
35
|
+
// Log why rebootstrap is happening
|
|
36
|
+
if (isBootstrapDecryptHint(hint.hintText) || isDecryptReject(err)) {
|
|
37
|
+
console.log("\n[auth] dispatch key expired — rebootstrap required");
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
console.log("\n[auth] runtime config rotated — refreshing bootstrap config...");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
bootstrapCfg = await refreshBootstrapFromGateway(args.targetEnv);
|
|
44
|
+
console.log("[auth] retrying dispatch with refreshed bootstrap config...");
|
|
45
|
+
accepted = await attemptDispatch(args.input, bootstrapCfg, args.teams, args.mode, args.targetEnv);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
const rawRetryMsg = String(e?.message || "").toLowerCase();
|
|
49
|
+
const retryableAfterRetry = rawRetryMsg.includes("fetch failed") ||
|
|
50
|
+
rawRetryMsg.includes("failed to reach gateway") ||
|
|
51
|
+
rawRetryMsg.includes("enotfound") ||
|
|
52
|
+
rawRetryMsg.includes("econn") ||
|
|
53
|
+
rawRetryMsg.includes("networkerror");
|
|
54
|
+
if (retryableAfterRetry) {
|
|
55
|
+
throw new DispatchRejectedError([
|
|
56
|
+
"Failed to reach the Viza gateway.",
|
|
57
|
+
"Your internet connection may be disconnected or unstable.",
|
|
58
|
+
"Please check your network connection and retry.",
|
|
59
|
+
].join(" "), { cause: e });
|
|
60
|
+
}
|
|
61
|
+
const hint2 = extractGatewayErrorHint(e);
|
|
62
|
+
const needsBootstrap2 = isBootstrapDecryptHint(hint2.hintText) || isDecryptReject(e);
|
|
63
|
+
if (needsBootstrap2) {
|
|
64
|
+
throw new DispatchRejectedError("Gateway rejected encrypted payload after bootstrap refresh.", { cause: e });
|
|
65
|
+
}
|
|
66
|
+
throw new DispatchRejectedError(`Dispatch rejected after retry${hint2.detail}`, { cause: e });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!accepted) {
|
|
70
|
+
throw new DispatchRejectedError("Dispatch failed: missing gateway acceptance result");
|
|
71
|
+
}
|
|
72
|
+
return { accepted, bootstrap: bootstrapCfg };
|
|
73
|
+
}
|
|
74
|
+
async function attemptDispatch(input, bootstrapCfg, teams, mode, targetEnv) {
|
|
75
|
+
// SECURITY-CRITICAL:
|
|
76
|
+
// Fail fast locally before sending encrypted payloads to the gateway.
|
|
77
|
+
//
|
|
78
|
+
// `teams` are the authenticated canonical GitHub teams resolved from JWT/session.
|
|
79
|
+
// `allowedTeams` is the command allowlist contract.
|
|
80
|
+
//
|
|
81
|
+
// Gateway MUST still re-verify authorization server-side.
|
|
82
|
+
if (!input.allowedTeams.some(team => teams.includes(team))) {
|
|
83
|
+
throw new DispatchRejectedError(`You do not have permission to run "${input.commandType}" in target environment "${targetEnv}".`);
|
|
84
|
+
}
|
|
85
|
+
const req = buildGatewayReq(input, bootstrapCfg, teams);
|
|
86
|
+
if (!req.payload) {
|
|
87
|
+
throw new Error("bootstrap_encryption_failed");
|
|
88
|
+
}
|
|
89
|
+
return dispatchToGateway(req, { endpoint: bootstrapCfg.endpoint }, mode);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* SECURITY-CRITICAL:
|
|
93
|
+
*
|
|
94
|
+
* `allowedTeams` MUST be forwarded to the gateway unchanged.
|
|
95
|
+
*
|
|
96
|
+
* Gateway/server authorization MUST verify that the authenticated
|
|
97
|
+
* GitHub identity extracted from JWT intersects with this allowlist.
|
|
98
|
+
*
|
|
99
|
+
* Removing or mutating this field weakens command-level authorization
|
|
100
|
+
* boundaries and may allow unauthorized command execution.
|
|
101
|
+
*/
|
|
102
|
+
function buildGatewayReq(input, cfg, teams) {
|
|
103
|
+
return {
|
|
104
|
+
intent: input.intent,
|
|
105
|
+
runType: input.runType,
|
|
106
|
+
infraKey: input.infraKey,
|
|
107
|
+
commandType: input.commandType,
|
|
108
|
+
allowedTeams: input.allowedTeams,
|
|
109
|
+
payload: encryptPayload(input.payload, {
|
|
110
|
+
serverPublicKeyRawB64: cfg.encrypt.publicKey,
|
|
111
|
+
}),
|
|
112
|
+
...(input.runnerLabel !== undefined ? { runnerLabel: input.runnerLabel } : {}),
|
|
113
|
+
...(input.keepLog !== undefined ? { keepLog: input.keepLog } : {}),
|
|
114
|
+
...(input.flowGates !== undefined ? { flowGates: input.flowGates } : {}),
|
|
115
|
+
resourceId: input.resourceId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function extractGatewayErrorHint(err) {
|
|
119
|
+
const rawMessage = String(err?.message || "").trim();
|
|
120
|
+
let structuredMessage = null;
|
|
121
|
+
const jsonStart = rawMessage.indexOf("{");
|
|
122
|
+
if (jsonStart >= 0) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(rawMessage.slice(jsonStart).trim());
|
|
125
|
+
structuredMessage = parsed?.error?.message ?? parsed?.message ?? null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// noop
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const hintText = structuredMessage ?? rawMessage;
|
|
132
|
+
return {
|
|
133
|
+
hintText,
|
|
134
|
+
detail: hintText ? `: ${hintText}` : "",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function isBootstrapDecryptHint(hintText) {
|
|
138
|
+
return (hintText.includes("encrypted_payload_invalid_or_decrypt_failed") ||
|
|
139
|
+
hintText.includes("decrypt_failed") ||
|
|
140
|
+
hintText.includes("run_viza_bootstrap"));
|
|
141
|
+
}
|
|
142
|
+
function isDecryptReject(err) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = String(err?.message || "");
|
|
145
|
+
const jsonStart = raw.indexOf("{");
|
|
146
|
+
if (jsonStart < 0)
|
|
147
|
+
return false;
|
|
148
|
+
const parsed = JSON.parse(raw.slice(jsonStart));
|
|
149
|
+
// New contract: server returns wrapped response with validation artifacts
|
|
150
|
+
// Check for reject action with encrypted payload in validation artifacts
|
|
151
|
+
return (parsed?.data?.action === "reject" &&
|
|
152
|
+
parsed?.data?.artifacts?.validation?.payload?.value?.alg === "x25519-hkdf-aes256gcm");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { GatewayDispatchRequest, GatewayDispatchAccepted } from "./types.js";
|
|
2
|
+
import { DispatchMode } from "../types/dispatcher.js";
|
|
3
|
+
export interface DispatchToGatewayOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Base HTTPS endpoint of the gateway worker
|
|
6
|
+
* Example: https://dispatch.viza.io
|
|
7
|
+
*/
|
|
8
|
+
endpoint: string;
|
|
9
|
+
/**
|
|
10
|
+
* Optional request timeout in ms (default: 15s)
|
|
11
|
+
*/
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* POST /dispatch
|
|
16
|
+
*
|
|
17
|
+
* Low-level gateway call.
|
|
18
|
+
* This function:
|
|
19
|
+
* - does NOT retry
|
|
20
|
+
* - does NOT handle UX
|
|
21
|
+
* - does NOT swallow errors
|
|
22
|
+
*/
|
|
23
|
+
export declare function dispatchToGateway(req: GatewayDispatchRequest, options: DispatchToGatewayOptions, mode?: DispatchMode): Promise<GatewayDispatchAccepted>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { gatewayFetch } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* POST /dispatch
|
|
4
|
+
*
|
|
5
|
+
* Low-level gateway call.
|
|
6
|
+
* This function:
|
|
7
|
+
* - does NOT retry
|
|
8
|
+
* - does NOT handle UX
|
|
9
|
+
* - does NOT swallow errors
|
|
10
|
+
*/
|
|
11
|
+
export async function dispatchToGateway(req, options, mode = "dispatch") {
|
|
12
|
+
const { endpoint, timeoutMs = 15_000 } = options;
|
|
13
|
+
if (!endpoint.startsWith("https://")) {
|
|
14
|
+
// Do not break the dispatcher flow. Treat this as a temporary gateway
|
|
15
|
+
// reachability failure so dispatcher can refresh bootstrap and retry.
|
|
16
|
+
return Promise.reject(new Error(`Failed to reach gateway: invalid endpoint (${endpoint})`));
|
|
17
|
+
}
|
|
18
|
+
const path = mode === "status"
|
|
19
|
+
? "/status"
|
|
20
|
+
: mode === "cancel"
|
|
21
|
+
? "/cancel"
|
|
22
|
+
: "/dispatch";
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await gatewayFetch({
|
|
26
|
+
url: `${endpoint}${path}`,
|
|
27
|
+
method: "POST",
|
|
28
|
+
body: req,
|
|
29
|
+
timeoutMs,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err?.name === "AbortError") {
|
|
34
|
+
throw new Error("\nGateway dispatch request timed out");
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`\nFailed to reach gateway: ${err?.message ?? err}`);
|
|
37
|
+
}
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const text = await safeReadText(res);
|
|
40
|
+
throw new Error(`\nGateway HTTP error ${res.status}: ${text || res.statusText}`);
|
|
41
|
+
}
|
|
42
|
+
let rawText;
|
|
43
|
+
try {
|
|
44
|
+
rawText = await res.text();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error("\nGateway returned unreadable body");
|
|
48
|
+
}
|
|
49
|
+
let json;
|
|
50
|
+
try {
|
|
51
|
+
json = JSON.parse(rawText);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error("\nGateway returned invalid JSON");
|
|
55
|
+
}
|
|
56
|
+
// New contract: unwrap envelope from { ok, data, error }
|
|
57
|
+
const envelope = (json && typeof json === "object" && "data" in json)
|
|
58
|
+
? json.data
|
|
59
|
+
: json;
|
|
60
|
+
// If server rejected, decide whether this is an encryption/decrypt-level
|
|
61
|
+
// rejection (which should trigger bootstrap refresh / retry) or a
|
|
62
|
+
// canonical runtime/business rejection which should be treated as a
|
|
63
|
+
// legitimate runtime outcome (DO NOT throw).
|
|
64
|
+
if (envelope?.action === "reject") {
|
|
65
|
+
// Detect encrypted-payload validation marker without using `any`.
|
|
66
|
+
const isDecryptReject = (() => {
|
|
67
|
+
try {
|
|
68
|
+
if (!envelope?.artifacts || typeof envelope.artifacts !== "object")
|
|
69
|
+
return false;
|
|
70
|
+
const artifacts = envelope.artifacts;
|
|
71
|
+
const validation = artifacts["validation"];
|
|
72
|
+
if (!validation || typeof validation !== "object")
|
|
73
|
+
return false;
|
|
74
|
+
const payload = validation["payload"];
|
|
75
|
+
if (!payload || typeof payload !== "object")
|
|
76
|
+
return false;
|
|
77
|
+
const value = payload["value"];
|
|
78
|
+
if (!value || typeof value !== "object")
|
|
79
|
+
return false;
|
|
80
|
+
const alg = value["alg"];
|
|
81
|
+
return alg === "x25519-hkdf-aes256gcm";
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
if (isDecryptReject) {
|
|
88
|
+
// Preserve previous behavior for decryption rejects so retry logic
|
|
89
|
+
// can detect and refresh bootstrap keys.
|
|
90
|
+
throw new Error(`\nGateway dispatch rejected: ${rawText}`);
|
|
91
|
+
}
|
|
92
|
+
// Otherwise, this is a canonical runtime/business rejection. Return
|
|
93
|
+
// the envelope so it can be normalized into a DispatchResult downstream.
|
|
94
|
+
return envelope;
|
|
95
|
+
}
|
|
96
|
+
if (!envelope ||
|
|
97
|
+
typeof envelope !== "object" ||
|
|
98
|
+
typeof envelope.action !== "string") {
|
|
99
|
+
throw new Error("\nGateway response does not match canonical dispatch contract");
|
|
100
|
+
}
|
|
101
|
+
return envelope;
|
|
102
|
+
}
|
|
103
|
+
async function safeReadText(res) {
|
|
104
|
+
try {
|
|
105
|
+
return await res.text();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DispatchInput, DispatchResult } from "../types/dispatcher.js";
|
|
2
|
+
import type { GatewayDispatchAccepted } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wait for the result of an accepted dispatch session.
|
|
5
|
+
*
|
|
6
|
+
* Routes through the GatewayDispatchAccepted action:
|
|
7
|
+
* - "reject" / "idle" → throws or returns early
|
|
8
|
+
* - "cancel" / "done" → returns immediately
|
|
9
|
+
* - "dispatch" / "reuse" → connects WebSocket, waits, finalizes
|
|
10
|
+
*/
|
|
11
|
+
export declare function waitResultFromAccepted(accepted: GatewayDispatchAccepted, input: DispatchInput): Promise<DispatchResult>;
|