@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.
Files changed (109) hide show
  1. package/README.md +43 -0
  2. package/dist/artifacts/builders.d.ts +32 -0
  3. package/dist/artifacts/builders.js +72 -0
  4. package/dist/artifacts/downloader.d.ts +28 -0
  5. package/dist/artifacts/downloader.js +102 -0
  6. package/dist/artifacts/normalizer.d.ts +22 -0
  7. package/dist/artifacts/normalizer.js +65 -0
  8. package/dist/artifacts/types.d.ts +94 -0
  9. package/dist/artifacts/types.js +1 -0
  10. package/dist/auth/identity/extract-identity.d.ts +8 -0
  11. package/dist/auth/identity/extract-identity.js +15 -0
  12. package/dist/auth/identity/normalize-teams.d.ts +1 -0
  13. package/dist/auth/identity/normalize-teams.js +5 -0
  14. package/dist/auth/identity/validate-claims.d.ts +2 -0
  15. package/dist/auth/identity/validate-claims.js +5 -0
  16. package/dist/auth/jwt/parse.d.ts +2 -0
  17. package/dist/auth/jwt/parse.js +16 -0
  18. package/dist/auth/jwt/verify.d.ts +1 -0
  19. package/dist/auth/jwt/verify.js +9 -0
  20. package/dist/auth/oauth/auth-error.d.ts +12 -0
  21. package/dist/auth/oauth/auth-error.js +47 -0
  22. package/dist/auth/oauth/auth-session-store.d.ts +30 -0
  23. package/dist/auth/oauth/auth-session-store.js +46 -0
  24. package/dist/auth/oauth/callback-server.d.ts +21 -0
  25. package/dist/auth/oauth/callback-server.js +319 -0
  26. package/dist/auth/oauth/github.d.ts +14 -0
  27. package/dist/auth/oauth/github.js +100 -0
  28. package/dist/auth/oauth/sse.d.ts +11 -0
  29. package/dist/auth/oauth/sse.js +67 -0
  30. package/dist/auth/oauth/templates/failed.html +629 -0
  31. package/dist/auth/oauth/templates/pending.html +620 -0
  32. package/dist/auth/oauth/templates/success.html +577 -0
  33. package/dist/auth/session/access-token.d.ts +15 -0
  34. package/dist/auth/session/access-token.js +64 -0
  35. package/dist/auth/session/login.d.ts +18 -0
  36. package/dist/auth/session/login.js +144 -0
  37. package/dist/auth/session/logout.d.ts +3 -0
  38. package/dist/auth/session/logout.js +21 -0
  39. package/dist/auth/session/refresh-token.d.ts +8 -0
  40. package/dist/auth/session/refresh-token.js +16 -0
  41. package/dist/auth/session/refresh.d.ts +2 -0
  42. package/dist/auth/session/refresh.js +61 -0
  43. package/dist/auth/session/rotate.d.ts +4 -0
  44. package/dist/auth/session/rotate.js +4 -0
  45. package/dist/auth/session/session-manager.d.ts +16 -0
  46. package/dist/auth/session/session-manager.js +54 -0
  47. package/dist/auth/session/token-state.d.ts +20 -0
  48. package/dist/auth/session/token-state.js +55 -0
  49. package/dist/auth/session/types.d.ts +35 -0
  50. package/dist/auth/session/types.js +1 -0
  51. package/dist/auth/storage/keychain.d.ts +16 -0
  52. package/dist/auth/storage/keychain.js +107 -0
  53. package/dist/auth/storage/memory.d.ts +10 -0
  54. package/dist/auth/storage/memory.js +15 -0
  55. package/dist/auth/storage/types.d.ts +5 -0
  56. package/dist/auth/storage/types.js +1 -0
  57. package/dist/config/defaults.d.ts +94 -0
  58. package/dist/config/defaults.js +116 -0
  59. package/dist/core/auth-bootstrap.d.ts +15 -0
  60. package/dist/core/auth-bootstrap.js +33 -0
  61. package/dist/core/bootstrap.d.ts +73 -0
  62. package/dist/core/bootstrap.js +248 -0
  63. package/dist/core/dispatcher.d.ts +2 -0
  64. package/dist/core/dispatcher.js +28 -0
  65. package/dist/core/ensure-bootstrap.d.ts +3 -0
  66. package/dist/core/ensure-bootstrap.js +25 -0
  67. package/dist/core/errors.d.ts +69 -0
  68. package/dist/core/errors.js +121 -0
  69. package/dist/core/runtime-orchestrator.d.ts +17 -0
  70. package/dist/core/runtime-orchestrator.js +47 -0
  71. package/dist/core/runtime.d.ts +16 -0
  72. package/dist/core/runtime.js +52 -0
  73. package/dist/core/validation.d.ts +2 -0
  74. package/dist/core/validation.js +21 -0
  75. package/dist/crypto/encrypt.d.ts +12 -0
  76. package/dist/crypto/encrypt.js +80 -0
  77. package/dist/flows/email-otp-flow.d.ts +7 -0
  78. package/dist/flows/email-otp-flow.js +249 -0
  79. package/dist/gateway/auth-header.d.ts +2 -0
  80. package/dist/gateway/auth-header.js +21 -0
  81. package/dist/gateway/client.d.ts +7 -0
  82. package/dist/gateway/client.js +24 -0
  83. package/dist/gateway/dispatch-with-bootstrap-retry.d.ts +25 -0
  84. package/dist/gateway/dispatch-with-bootstrap-retry.js +157 -0
  85. package/dist/gateway/dispatch.d.ts +23 -0
  86. package/dist/gateway/dispatch.js +110 -0
  87. package/dist/gateway/session-waiter.d.ts +11 -0
  88. package/dist/gateway/session-waiter.js +126 -0
  89. package/dist/gateway/session.d.ts +21 -0
  90. package/dist/gateway/session.js +74 -0
  91. package/dist/gateway/types.d.ts +113 -0
  92. package/dist/gateway/types.js +19 -0
  93. package/dist/index.d.ts +22 -0
  94. package/dist/index.js +21 -0
  95. package/dist/runtime/runtime-context.d.ts +6 -0
  96. package/dist/runtime/runtime-context.js +1 -0
  97. package/dist/types/dispatcher.d.ts +275 -0
  98. package/dist/types/dispatcher.js +1 -0
  99. package/dist/types/payload.d.ts +5 -0
  100. package/dist/types/payload.js +2 -0
  101. package/dist/types/runtime-env.d.ts +2 -0
  102. package/dist/types/runtime-env.js +5 -0
  103. package/dist/ui/banner.d.ts +4 -0
  104. package/dist/ui/banner.js +20 -0
  105. package/dist/utils/request-dedup.d.ts +75 -0
  106. package/dist/utils/request-dedup.js +102 -0
  107. package/dist/utils/type-guards.d.ts +173 -0
  108. package/dist/utils/type-guards.js +232 -0
  109. 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,2 @@
1
+ export declare function buildAuthHeader(): Promise<Record<string, string>>;
2
+ export declare function withAuthRetry<T>(run: (headers: Record<string, string>) => Promise<T>, shouldRefresh: (result: T) => boolean): Promise<T>;
@@ -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,7 @@
1
+ export interface GatewayClientRequest {
2
+ url: string;
3
+ method: "POST" | "GET";
4
+ body?: unknown;
5
+ timeoutMs?: number;
6
+ }
7
+ export declare function gatewayFetch(request: GatewayClientRequest): Promise<Response>;
@@ -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>;