libretto 0.6.24 → 0.6.26
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 +9 -1
- package/README.template.md +9 -1
- package/dist/cli/commands/browser.js +17 -10
- package/dist/cli/commands/cloud-credentials.js +70 -0
- package/dist/cli/commands/deploy.js +24 -2
- package/dist/cli/commands/execution.js +9 -30
- package/dist/cli/commands/import-chrome-profiles.js +46 -0
- package/dist/cli/commands/profiles.js +71 -0
- package/dist/cli/commands/shared.js +1 -3
- package/dist/cli/core/browser.js +89 -75
- package/dist/cli/core/daemon/daemon.js +47 -35
- package/dist/cli/core/daemon/ipc.js +3 -0
- package/dist/cli/core/deploy-artifact.js +85 -22
- package/dist/cli/core/profiles.js +47 -0
- package/dist/cli/core/prompt.js +9 -0
- package/dist/cli/core/providers/libretto-cloud.js +6 -2
- package/dist/cli/core/session-logs.js +325 -0
- package/dist/cli/core/telemetry.js +110 -311
- package/dist/cli/core/workflow-runner/runner.js +65 -0
- package/dist/cli/router.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/shared/workflow/auth-profile-name.d.ts +3 -0
- package/dist/shared/workflow/auth-profile-name.js +29 -0
- package/dist/shared/workflow/auth-profile-state.d.ts +20 -0
- package/dist/shared/workflow/auth-profile-state.js +105 -0
- package/dist/shared/workflow/authenticate.d.ts +17 -0
- package/dist/shared/workflow/authenticate.js +37 -0
- package/dist/shared/workflow/credentials.d.ts +5 -0
- package/dist/shared/workflow/credentials.js +68 -0
- package/dist/shared/workflow/workflow.d.ts +16 -1
- package/dist/shared/workflow/workflow.js +56 -4
- package/package.json +1 -1
- package/skills/libretto/SKILL.md +3 -4
- package/skills/libretto/references/auth-profiles.md +61 -11
- package/skills/libretto/references/code-generation-rules.md +31 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/browser.ts +19 -11
- package/src/cli/commands/cloud-credentials.ts +82 -0
- package/src/cli/commands/deploy.ts +41 -2
- package/src/cli/commands/execution.ts +10 -31
- package/src/cli/commands/import-chrome-profiles.ts +46 -0
- package/src/cli/commands/profiles.ts +90 -0
- package/src/cli/commands/shared.ts +4 -8
- package/src/cli/core/browser.ts +102 -91
- package/src/cli/core/daemon/config.ts +4 -1
- package/src/cli/core/daemon/daemon.ts +52 -44
- package/src/cli/core/daemon/ipc.ts +15 -0
- package/src/cli/core/deploy-artifact.ts +131 -32
- package/src/cli/core/profiles.ts +53 -0
- package/src/cli/core/prompt.ts +15 -0
- package/src/cli/core/providers/libretto-cloud.ts +6 -2
- package/src/cli/core/providers/types.ts +4 -1
- package/src/cli/core/session-logs.ts +445 -0
- package/src/cli/core/telemetry.ts +142 -413
- package/src/cli/core/workflow-runner/runner.ts +86 -1
- package/src/cli/router.ts +8 -0
- package/src/index.ts +10 -0
- package/src/shared/workflow/auth-profile-name.ts +27 -0
- package/src/shared/workflow/auth-profile-state.ts +144 -0
- package/src/shared/workflow/authenticate.ts +63 -0
- package/src/shared/workflow/credentials.ts +91 -0
- package/src/shared/workflow/workflow.ts +89 -4
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import type { BrowserContext, Page } from "playwright";
|
|
1
|
+
import type { BrowserContext, Frame, Page } from "playwright";
|
|
2
2
|
import type { LoggerApi } from "../../../shared/logger/index.js";
|
|
3
3
|
import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
|
|
4
|
+
import {
|
|
5
|
+
mergeAuthProfileStorageState,
|
|
6
|
+
normalizeAuthProfileSite,
|
|
7
|
+
} from "../../../shared/workflow/auth-profile-state.js";
|
|
4
8
|
import type {
|
|
5
9
|
ExportedLibrettoWorkflow,
|
|
6
10
|
LibrettoWorkflowContext,
|
|
7
11
|
} from "../../../shared/workflow/workflow.js";
|
|
12
|
+
import { readProfile, writeProfile } from "../profiles.js";
|
|
8
13
|
import {
|
|
9
14
|
getAbsoluteIntegrationPath,
|
|
10
15
|
installHeadedWorkflowVisualization,
|
|
@@ -44,6 +49,7 @@ export type WorkflowControllerConfig = {
|
|
|
44
49
|
page: Page;
|
|
45
50
|
context: BrowserContext;
|
|
46
51
|
logger: LoggerApi;
|
|
52
|
+
refreshLocalAuthProfiles?: boolean;
|
|
47
53
|
onLog?: (event: WorkflowLogEvent) => void;
|
|
48
54
|
onOutcome?: (outcome: WorkflowOutcome) => void;
|
|
49
55
|
};
|
|
@@ -155,6 +161,7 @@ export class WorkflowController {
|
|
|
155
161
|
session: this.config.session,
|
|
156
162
|
page: this.config.page,
|
|
157
163
|
};
|
|
164
|
+
const visitedSites = createVisitedSiteTracker(this.config.context);
|
|
158
165
|
|
|
159
166
|
const uninstallPauseHandler = installPauseHandler((pauseArgs) =>
|
|
160
167
|
this.pause({
|
|
@@ -164,6 +171,12 @@ export class WorkflowController {
|
|
|
164
171
|
);
|
|
165
172
|
try {
|
|
166
173
|
await workflow.run(workflowContext, workflowConfig.params ?? {});
|
|
174
|
+
await refreshLocalAuthProfileIfEnabled({
|
|
175
|
+
context: this.config.context,
|
|
176
|
+
enabled: this.config.refreshLocalAuthProfiles === true,
|
|
177
|
+
sites: visitedSites.sites(),
|
|
178
|
+
workflow,
|
|
179
|
+
});
|
|
167
180
|
} catch (error) {
|
|
168
181
|
this.emitOutcome({
|
|
169
182
|
state: "finished",
|
|
@@ -174,6 +187,7 @@ export class WorkflowController {
|
|
|
174
187
|
return;
|
|
175
188
|
} finally {
|
|
176
189
|
uninstallPauseHandler();
|
|
190
|
+
visitedSites.dispose();
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
this.emitOutcome({
|
|
@@ -232,6 +246,77 @@ export class WorkflowController {
|
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
|
|
249
|
+
async function refreshLocalAuthProfileIfEnabled(
|
|
250
|
+
args: {
|
|
251
|
+
context: BrowserContext;
|
|
252
|
+
enabled: boolean;
|
|
253
|
+
sites: readonly string[];
|
|
254
|
+
workflow: ExportedLibrettoWorkflow;
|
|
255
|
+
},
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const { context, enabled, sites, workflow } = args;
|
|
258
|
+
if (!workflow.authProfileName || workflow.authProfileRefresh !== true) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (!enabled) return;
|
|
262
|
+
if (sites.length === 0) {
|
|
263
|
+
console.warn(
|
|
264
|
+
`Auth profile refresh skipped for "${workflow.authProfileName}": workflow did not visit any http(s) sites.`,
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const existing = readProfile(workflow.authProfileName);
|
|
269
|
+
const latest = await context.storageState({ indexedDB: true });
|
|
270
|
+
const state = mergeAuthProfileStorageState(existing, latest, sites);
|
|
271
|
+
await writeProfile(workflow.authProfileName, state);
|
|
272
|
+
console.warn(`Auth profile refreshed: ${workflow.authProfileName}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function createVisitedSiteTracker(context: BrowserContext): {
|
|
276
|
+
sites: () => string[];
|
|
277
|
+
dispose: () => void;
|
|
278
|
+
} {
|
|
279
|
+
const sites = new Set<string>();
|
|
280
|
+
const pageListeners = new Map<Page, (frame: Frame) => void>();
|
|
281
|
+
|
|
282
|
+
const recordUrl = (url: string): void => {
|
|
283
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) return;
|
|
284
|
+
const site = normalizeAuthProfileSite(url);
|
|
285
|
+
if (site) sites.add(site);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const trackPage = (page: Page): void => {
|
|
289
|
+
if (pageListeners.has(page)) return;
|
|
290
|
+
recordUrl(page.url());
|
|
291
|
+
|
|
292
|
+
const onFrameNavigated = (frame: Frame): void => {
|
|
293
|
+
if (frame === page.mainFrame()) {
|
|
294
|
+
recordUrl(frame.url());
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
pageListeners.set(page, onFrameNavigated);
|
|
299
|
+
page.on("framenavigated", onFrameNavigated);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
for (const page of context.pages()) {
|
|
303
|
+
trackPage(page);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
context.on("page", trackPage);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
sites: () => [...sites],
|
|
310
|
+
dispose: () => {
|
|
311
|
+
context.off("page", trackPage);
|
|
312
|
+
for (const [page, listener] of pageListeners) {
|
|
313
|
+
page.off("framenavigated", listener);
|
|
314
|
+
}
|
|
315
|
+
pageListeners.clear();
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
235
320
|
function chunkToString(chunk: unknown): string {
|
|
236
321
|
return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
237
322
|
}
|
package/src/cli/router.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { authCommands } from "./commands/auth.js";
|
|
2
2
|
import { billingCommands } from "./commands/billing.js";
|
|
3
3
|
import { browserCommands } from "./commands/browser.js";
|
|
4
|
+
import { cloudCredentialCommands } from "./commands/cloud-credentials.js";
|
|
4
5
|
import { deployCommand } from "./commands/deploy.js";
|
|
5
6
|
import { executionCommands } from "./commands/execution.js";
|
|
6
7
|
import { experimentsCommand } from "./commands/experiments.js";
|
|
8
|
+
import { importChromeProfilesCommand } from "./commands/import-chrome-profiles.js";
|
|
9
|
+
import { profileCommands } from "./commands/profiles.js";
|
|
7
10
|
import { setupCommand } from "./commands/setup.js";
|
|
8
11
|
import { statusCommand } from "./commands/status.js";
|
|
9
12
|
import { snapshotCommand } from "./commands/snapshot.js";
|
|
10
13
|
import { searchCommand } from "./commands/search.js";
|
|
14
|
+
import { telemetryMiddleware } from "./core/telemetry.js";
|
|
11
15
|
import { updateCommand } from "./commands/update.js";
|
|
12
16
|
import { SimpleCLI } from "affordance";
|
|
13
17
|
|
|
@@ -19,9 +23,12 @@ export const cliRoutes = {
|
|
|
19
23
|
deploy: deployCommand,
|
|
20
24
|
auth: authCommands,
|
|
21
25
|
billing: billingCommands,
|
|
26
|
+
credentials: cloudCredentialCommands,
|
|
27
|
+
profiles: profileCommands,
|
|
22
28
|
},
|
|
23
29
|
}),
|
|
24
30
|
experiments: experimentsCommand,
|
|
31
|
+
"import-chrome-profiles": importChromeProfilesCommand,
|
|
25
32
|
...executionCommands,
|
|
26
33
|
search: searchCommand,
|
|
27
34
|
setup: setupCommand,
|
|
@@ -32,6 +39,7 @@ export const cliRoutes = {
|
|
|
32
39
|
|
|
33
40
|
export function createCLIApp() {
|
|
34
41
|
return SimpleCLI.define("libretto", cliRoutes, {
|
|
42
|
+
middlewares: [telemetryMiddleware],
|
|
35
43
|
appendHelpText: [
|
|
36
44
|
"Options:",
|
|
37
45
|
" --session <name> Required for session-scoped commands",
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,10 @@ export {
|
|
|
112
112
|
} from "./shared/run/api.js";
|
|
113
113
|
|
|
114
114
|
// Workflow helpers
|
|
115
|
+
export {
|
|
116
|
+
librettoAuthenticate,
|
|
117
|
+
type LibrettoAuthenticateOptions,
|
|
118
|
+
} from "./shared/workflow/authenticate.js";
|
|
115
119
|
export {
|
|
116
120
|
getDefaultWorkflowFromModuleExports,
|
|
117
121
|
getWorkflowFromModuleExports,
|
|
@@ -128,6 +132,12 @@ export {
|
|
|
128
132
|
type LibrettoWorkflowOptions,
|
|
129
133
|
type WorkflowInputValidator,
|
|
130
134
|
} from "./shared/workflow/workflow.js";
|
|
135
|
+
export {
|
|
136
|
+
captureAuthProfileStorageState,
|
|
137
|
+
normalizeAuthProfileSite,
|
|
138
|
+
parseAuthProfileSites,
|
|
139
|
+
type AuthProfileStorageState,
|
|
140
|
+
} from "./shared/workflow/auth-profile-state.js";
|
|
131
141
|
const isDirectExecution = (): boolean => {
|
|
132
142
|
const entryArg = process.argv[1];
|
|
133
143
|
if (!entryArg) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function normalizeProfileName(name: string): string {
|
|
2
|
+
const trimmed = name.trim();
|
|
3
|
+
if (!trimmed) {
|
|
4
|
+
throw new Error("Profile name is required.");
|
|
5
|
+
}
|
|
6
|
+
if (!isValidProfileName(trimmed)) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`Invalid profile name "${name}". Use letters, numbers, dots, underscores, and dashes only.`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isValidProfileName(name: string): boolean {
|
|
15
|
+
if (name === "." || name === "..") return false;
|
|
16
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
17
|
+
const code = name.charCodeAt(index);
|
|
18
|
+
const isUppercase = code >= 65 && code <= 90;
|
|
19
|
+
const isLowercase = code >= 97 && code <= 122;
|
|
20
|
+
const isDigit = code >= 48 && code <= 57;
|
|
21
|
+
const isAllowedPunctuation = code === 45 || code === 46 || code === 95;
|
|
22
|
+
if (!isUppercase && !isLowercase && !isDigit && !isAllowedPunctuation) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { BrowserContext } from "playwright";
|
|
2
|
+
|
|
3
|
+
export type AuthProfileStorageState = {
|
|
4
|
+
sites?: string[];
|
|
5
|
+
cookies?: unknown[];
|
|
6
|
+
origins?: Array<{
|
|
7
|
+
origin: string;
|
|
8
|
+
localStorage: Array<{ name: string; value: string }>;
|
|
9
|
+
indexedDB?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function parseAuthProfileSites(value: string): string[] {
|
|
14
|
+
const sites = value
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((entry) => normalizeAuthProfileSite(entry))
|
|
17
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
18
|
+
return [...new Set(sites)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeAuthProfileSite(value: string): string | null {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (!trimmed) return null;
|
|
24
|
+
try {
|
|
25
|
+
const url = trimmed.includes("://")
|
|
26
|
+
? new URL(trimmed)
|
|
27
|
+
: new URL(`https://${trimmed}`);
|
|
28
|
+
return normalizeHost(url.hostname);
|
|
29
|
+
} catch {
|
|
30
|
+
const normalized = normalizeHost(trimmed);
|
|
31
|
+
return normalized || null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function captureAuthProfileStorageState(
|
|
36
|
+
context: BrowserContext,
|
|
37
|
+
sites: readonly string[],
|
|
38
|
+
): Promise<AuthProfileStorageState> {
|
|
39
|
+
const normalizedSites = [...new Set(
|
|
40
|
+
sites
|
|
41
|
+
.map((site) => normalizeAuthProfileSite(site))
|
|
42
|
+
.filter((site): site is string => Boolean(site)),
|
|
43
|
+
)];
|
|
44
|
+
if (normalizedSites.length === 0) {
|
|
45
|
+
throw new Error("At least one auth profile site is required.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const state = await context.storageState({ indexedDB: true });
|
|
49
|
+
return {
|
|
50
|
+
sites: normalizedSites,
|
|
51
|
+
cookies: state.cookies.filter((cookie) =>
|
|
52
|
+
cookieDomainMatchesSites(cookie.domain, normalizedSites),
|
|
53
|
+
),
|
|
54
|
+
origins: state.origins.filter((origin) =>
|
|
55
|
+
originMatchesSites(origin.origin, normalizedSites),
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mergeAuthProfileStorageState(
|
|
61
|
+
existing: AuthProfileStorageState,
|
|
62
|
+
latest: AuthProfileStorageState,
|
|
63
|
+
sites: readonly string[],
|
|
64
|
+
): AuthProfileStorageState {
|
|
65
|
+
const normalizedSites = [...new Set(
|
|
66
|
+
sites
|
|
67
|
+
.map((site) => normalizeAuthProfileSite(site))
|
|
68
|
+
.filter((site): site is string => Boolean(site)),
|
|
69
|
+
)];
|
|
70
|
+
if (normalizedSites.length === 0) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const existingCookies = existing.cookies ?? [];
|
|
75
|
+
const latestCookies = latest.cookies ?? [];
|
|
76
|
+
const existingOrigins = existing.origins ?? [];
|
|
77
|
+
const latestOrigins = latest.origins ?? [];
|
|
78
|
+
|
|
79
|
+
const mergedSites = [
|
|
80
|
+
...new Set([
|
|
81
|
+
...(existing.sites ?? []),
|
|
82
|
+
...normalizedSites,
|
|
83
|
+
]),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...existing,
|
|
88
|
+
sites: mergedSites,
|
|
89
|
+
cookies: [
|
|
90
|
+
...existingCookies.filter(
|
|
91
|
+
(cookie) => !cookieMatchesSites(cookie, normalizedSites),
|
|
92
|
+
),
|
|
93
|
+
...latestCookies.filter((cookie) =>
|
|
94
|
+
cookieMatchesSites(cookie, normalizedSites),
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
origins: [
|
|
98
|
+
...existingOrigins.filter(
|
|
99
|
+
(origin) => !originMatchesSites(origin.origin, normalizedSites),
|
|
100
|
+
),
|
|
101
|
+
...latestOrigins.filter((origin) =>
|
|
102
|
+
originMatchesSites(origin.origin, normalizedSites),
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeHost(value: string): string {
|
|
109
|
+
let host = value.trim().toLowerCase();
|
|
110
|
+
while (host.startsWith(".")) host = host.slice(1);
|
|
111
|
+
while (host.endsWith(".")) host = host.slice(0, -1);
|
|
112
|
+
return host;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function cookieMatchesSites(cookie: unknown, sites: readonly string[]): boolean {
|
|
116
|
+
if (!cookie || typeof cookie !== "object" || !("domain" in cookie)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const domain = (cookie as { domain?: unknown }).domain;
|
|
120
|
+
return typeof domain === "string" && cookieDomainMatchesSites(domain, sites);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cookieDomainMatchesSites(
|
|
124
|
+
cookieDomain: string,
|
|
125
|
+
sites: readonly string[],
|
|
126
|
+
): boolean {
|
|
127
|
+
const domain = normalizeHost(cookieDomain);
|
|
128
|
+
if (!domain) return false;
|
|
129
|
+
return sites.some(
|
|
130
|
+
(site) =>
|
|
131
|
+
domain === site ||
|
|
132
|
+
domain.endsWith(`.${site}`) ||
|
|
133
|
+
site.endsWith(`.${domain}`),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function originMatchesSites(origin: string, sites: readonly string[]): boolean {
|
|
138
|
+
try {
|
|
139
|
+
const host = normalizeHost(new URL(origin).hostname);
|
|
140
|
+
return sites.some((site) => host === site || host.endsWith(`.${site}`));
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { LibrettoWorkflowContext } from "./workflow.js";
|
|
2
|
+
|
|
3
|
+
export type LibrettoAuthenticateOptions = {
|
|
4
|
+
validate: (ctx: LibrettoWorkflowContext) => Promise<boolean> | boolean;
|
|
5
|
+
fallback: (
|
|
6
|
+
ctx: LibrettoWorkflowContext,
|
|
7
|
+
credentials: Record<string, string>,
|
|
8
|
+
) => Promise<void> | void;
|
|
9
|
+
credentials?: Record<string, unknown>;
|
|
10
|
+
envPrefix?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function librettoAuthenticate(
|
|
14
|
+
ctx: LibrettoWorkflowContext,
|
|
15
|
+
options: LibrettoAuthenticateOptions,
|
|
16
|
+
): Promise<{ usedProfile: boolean }> {
|
|
17
|
+
if (await options.validate(ctx)) {
|
|
18
|
+
return { usedProfile: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const credentials = normalizeCredentials(
|
|
22
|
+
options.credentials ?? readCredentialsFromEnv(options.envPrefix),
|
|
23
|
+
);
|
|
24
|
+
await options.fallback(ctx, credentials);
|
|
25
|
+
|
|
26
|
+
if (!(await options.validate(ctx))) {
|
|
27
|
+
throw new Error("Authentication fallback completed, but validation still failed.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { usedProfile: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeCredentials(
|
|
34
|
+
credentials: Record<string, unknown>,
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
const normalized: Record<string, string> = {};
|
|
37
|
+
for (const [key, value] of Object.entries(credentials)) {
|
|
38
|
+
if (typeof value === "string") normalized[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readCredentialsFromEnv(envPrefix = "LIBRETTO_"): Record<string, string> {
|
|
44
|
+
const credentials: Record<string, string> = {};
|
|
45
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
46
|
+
if (key.startsWith(envPrefix) && value !== undefined) {
|
|
47
|
+
const credentialName = key.slice(envPrefix.length).toLowerCase();
|
|
48
|
+
if (isLibrettoControlCredential(envPrefix, credentialName)) continue;
|
|
49
|
+
credentials[credentialName] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return credentials;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLibrettoControlCredential(
|
|
56
|
+
envPrefix: string,
|
|
57
|
+
credentialName: string,
|
|
58
|
+
): boolean {
|
|
59
|
+
return (
|
|
60
|
+
envPrefix === "LIBRETTO_" &&
|
|
61
|
+
["api_key", "api_url", "timeout_seconds"].includes(credentialName)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
function asStringMap(value: unknown): Record<string, string> {
|
|
2
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3
|
+
return {};
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
8
|
+
if (typeof rawValue === "string") result[key] = rawValue;
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readHostedCredentials(input: unknown): Record<string, string> {
|
|
14
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const record = input as Record<string, unknown>;
|
|
19
|
+
return asStringMap(record.credentials);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readCredentialInputsFromEnv(
|
|
23
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
24
|
+
): Record<string, string> {
|
|
25
|
+
const credentials: Record<string, string> = {};
|
|
26
|
+
for (const [key, value] of Object.entries(env)) {
|
|
27
|
+
if (!key.startsWith("LIBRETTO_CLOUD_") || value === undefined) continue;
|
|
28
|
+
if (value.trim().length === 0) continue;
|
|
29
|
+
const name = key.slice("LIBRETTO_CLOUD_".length).toLowerCase();
|
|
30
|
+
if (!name || name === "api_key") continue;
|
|
31
|
+
credentials[name] = value;
|
|
32
|
+
}
|
|
33
|
+
return credentials;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeCredentialNames(
|
|
37
|
+
names: readonly string[] | undefined,
|
|
38
|
+
): string[] {
|
|
39
|
+
if (!names) return [];
|
|
40
|
+
const normalized = new Set<string>();
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const value = name.trim().toLowerCase();
|
|
43
|
+
if (value.length > 0) normalized.add(value);
|
|
44
|
+
}
|
|
45
|
+
return [...normalized];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function filterCredentialMap(
|
|
49
|
+
credentials: Record<string, string>,
|
|
50
|
+
names: readonly string[],
|
|
51
|
+
): Record<string, string> {
|
|
52
|
+
const result: Record<string, string> = {};
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (credentials[name] !== undefined) result[name] = credentials[name];
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shouldReadCredentialInputsFromEnv(env: NodeJS.ProcessEnv): boolean {
|
|
60
|
+
return (env.LIBRETTO_HOSTED_RUNTIME?.trim().length ?? 0) === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function mergeCredentialsIntoInput(
|
|
64
|
+
input: unknown,
|
|
65
|
+
credentialNames?: readonly string[],
|
|
66
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
67
|
+
): unknown {
|
|
68
|
+
const normalizedNames = normalizeCredentialNames(credentialNames);
|
|
69
|
+
if (normalizedNames.length === 0) return input;
|
|
70
|
+
|
|
71
|
+
const existingCredentials = readHostedCredentials(input);
|
|
72
|
+
const envCredentials = shouldReadCredentialInputsFromEnv(env)
|
|
73
|
+
? readCredentialInputsFromEnv(env)
|
|
74
|
+
: {};
|
|
75
|
+
const mergedCredentials = {
|
|
76
|
+
...filterCredentialMap(envCredentials, normalizedNames),
|
|
77
|
+
...filterCredentialMap(existingCredentials, normalizedNames),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (Object.keys(mergedCredentials).length === 0) return input;
|
|
81
|
+
|
|
82
|
+
const base =
|
|
83
|
+
input && typeof input === "object" && !Array.isArray(input)
|
|
84
|
+
? { ...(input as Record<string, unknown>) }
|
|
85
|
+
: {};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...base,
|
|
89
|
+
credentials: mergedCredentials,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
createRecoveryPage,
|
|
5
5
|
type RecoveryAction,
|
|
6
6
|
} from "../../runtime/recovery/page-fallbacks.js";
|
|
7
|
+
import { normalizeProfileName } from "./auth-profile-name.js";
|
|
8
|
+
import {
|
|
9
|
+
mergeCredentialsIntoInput,
|
|
10
|
+
normalizeCredentialNames,
|
|
11
|
+
} from "./credentials.js";
|
|
7
12
|
|
|
8
13
|
export const LIBRETTO_WORKFLOW_BRAND = Symbol.for("libretto.workflow");
|
|
9
14
|
|
|
@@ -17,12 +22,21 @@ export type LibrettoWorkflowHandler<Input = unknown, Output = unknown> = (
|
|
|
17
22
|
input: Input,
|
|
18
23
|
) => Promise<Output>;
|
|
19
24
|
|
|
25
|
+
export type LibrettoWorkflowAuthProfile =
|
|
26
|
+
| string
|
|
27
|
+
| {
|
|
28
|
+
name: string;
|
|
29
|
+
refresh?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
export type LibrettoWorkflowDefinition<
|
|
21
33
|
InputSchema extends z.ZodType = z.ZodType<unknown>,
|
|
22
34
|
OutputSchema extends z.ZodType = z.ZodType<unknown>,
|
|
23
35
|
> = {
|
|
24
36
|
input?: InputSchema;
|
|
25
37
|
output?: OutputSchema;
|
|
38
|
+
credentials?: readonly string[];
|
|
39
|
+
authProfile?: LibrettoWorkflowAuthProfile;
|
|
26
40
|
recoveryAction?: RecoveryAction;
|
|
27
41
|
};
|
|
28
42
|
|
|
@@ -72,10 +86,44 @@ function parseWorkflowInput<InputSchema extends z.ZodType>(
|
|
|
72
86
|
if (!inputSchema) return input as z.infer<InputSchema>;
|
|
73
87
|
|
|
74
88
|
const result = inputSchema.safeParse(input);
|
|
75
|
-
if (
|
|
76
|
-
|
|
89
|
+
if (result.success) {
|
|
90
|
+
return reattachCredentialInput(result.data, input) as z.infer<InputSchema>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const stripped = stripCredentialInput(input);
|
|
94
|
+
if (stripped !== input) {
|
|
95
|
+
const strippedResult = inputSchema.safeParse(stripped);
|
|
96
|
+
if (strippedResult.success) {
|
|
97
|
+
return reattachCredentialInput(strippedResult.data, input) as z.infer<InputSchema>;
|
|
98
|
+
}
|
|
77
99
|
}
|
|
78
|
-
|
|
100
|
+
|
|
101
|
+
throw new LibrettoWorkflowInputError(workflowName, result.error);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stripCredentialInput(input: unknown): unknown {
|
|
105
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
|
106
|
+
const { credentials: _credentials, ...rest } = input as Record<string, unknown>;
|
|
107
|
+
if (!("credentials" in (input as Record<string, unknown>))) return input;
|
|
108
|
+
return rest;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function reattachCredentialInput(parsed: unknown, raw: unknown): unknown {
|
|
112
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return parsed;
|
|
113
|
+
const rawRecord = raw as Record<string, unknown>;
|
|
114
|
+
const rawCredentials =
|
|
115
|
+
rawRecord.credentials && typeof rawRecord.credentials === "object"
|
|
116
|
+
? rawRecord.credentials
|
|
117
|
+
: undefined;
|
|
118
|
+
if (!rawCredentials) return parsed;
|
|
119
|
+
const parsedRecord =
|
|
120
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
121
|
+
? { ...(parsed as Record<string, unknown>) }
|
|
122
|
+
: {};
|
|
123
|
+
return {
|
|
124
|
+
...parsedRecord,
|
|
125
|
+
credentials: rawCredentials,
|
|
126
|
+
};
|
|
79
127
|
}
|
|
80
128
|
|
|
81
129
|
export type WorkflowInputValidator = {
|
|
@@ -104,6 +152,9 @@ export class LibrettoWorkflow<
|
|
|
104
152
|
// this schema to JSON Schema at build time and exposes it via
|
|
105
153
|
// /v1/workflows/get so API consumers know the workflow's output shape.
|
|
106
154
|
public readonly outputSchema?: OutputSchema;
|
|
155
|
+
public readonly credentialNames: readonly string[];
|
|
156
|
+
public readonly authProfileName?: string;
|
|
157
|
+
public readonly authProfileRefresh?: boolean;
|
|
107
158
|
public readonly recoveryAction?: RecoveryAction;
|
|
108
159
|
private readonly handler: LibrettoWorkflowHandler<
|
|
109
160
|
z.infer<InputSchema>,
|
|
@@ -116,6 +167,9 @@ export class LibrettoWorkflow<
|
|
|
116
167
|
| {
|
|
117
168
|
inputSchema?: InputSchema;
|
|
118
169
|
outputSchema?: OutputSchema;
|
|
170
|
+
credentialNames?: readonly string[];
|
|
171
|
+
authProfileName?: string;
|
|
172
|
+
authProfileRefresh?: boolean;
|
|
119
173
|
recoveryAction?: RecoveryAction;
|
|
120
174
|
}
|
|
121
175
|
| undefined,
|
|
@@ -127,6 +181,9 @@ export class LibrettoWorkflow<
|
|
|
127
181
|
this.name = name;
|
|
128
182
|
this.inputSchema = options?.inputSchema;
|
|
129
183
|
this.outputSchema = options?.outputSchema;
|
|
184
|
+
this.credentialNames = options?.credentialNames ?? [];
|
|
185
|
+
this.authProfileName = options?.authProfileName;
|
|
186
|
+
this.authProfileRefresh = options?.authProfileRefresh;
|
|
130
187
|
this.recoveryAction = options?.recoveryAction;
|
|
131
188
|
this.handler = handler;
|
|
132
189
|
}
|
|
@@ -135,7 +192,11 @@ export class LibrettoWorkflow<
|
|
|
135
192
|
ctx: LibrettoWorkflowContext,
|
|
136
193
|
input: unknown,
|
|
137
194
|
): Promise<z.infer<OutputSchema>> {
|
|
138
|
-
const parsed = parseWorkflowInput(
|
|
195
|
+
const parsed = parseWorkflowInput(
|
|
196
|
+
this.name,
|
|
197
|
+
this.inputSchema,
|
|
198
|
+
mergeCredentialsIntoInput(input, this.credentialNames),
|
|
199
|
+
);
|
|
139
200
|
const workflowContext =
|
|
140
201
|
!this.recoveryAction
|
|
141
202
|
? ctx
|
|
@@ -154,6 +215,9 @@ export type ExportedLibrettoWorkflow = {
|
|
|
154
215
|
readonly name: string;
|
|
155
216
|
readonly inputSchema?: z.ZodType;
|
|
156
217
|
readonly outputSchema?: z.ZodType;
|
|
218
|
+
readonly credentialNames: readonly string[];
|
|
219
|
+
readonly authProfileName?: string;
|
|
220
|
+
readonly authProfileRefresh?: boolean;
|
|
157
221
|
readonly recoveryAction?: RecoveryAction;
|
|
158
222
|
run: (ctx: LibrettoWorkflowContext, input: unknown) => Promise<unknown>;
|
|
159
223
|
};
|
|
@@ -255,11 +319,18 @@ function getWorkflowConstructorOptions<
|
|
|
255
319
|
): {
|
|
256
320
|
inputSchema?: InputSchema;
|
|
257
321
|
outputSchema?: OutputSchema;
|
|
322
|
+
credentialNames: readonly string[];
|
|
323
|
+
authProfileName?: string;
|
|
324
|
+
authProfileRefresh?: boolean;
|
|
258
325
|
recoveryAction?: RecoveryAction;
|
|
259
326
|
} {
|
|
327
|
+
const authProfile = normalizeWorkflowAuthProfile(options.authProfile);
|
|
260
328
|
return {
|
|
261
329
|
inputSchema: options.input,
|
|
262
330
|
outputSchema: options.output,
|
|
331
|
+
credentialNames: normalizeCredentialNames(options.credentials),
|
|
332
|
+
authProfileName: authProfile?.name,
|
|
333
|
+
authProfileRefresh: authProfile?.refresh,
|
|
263
334
|
recoveryAction: options.recoveryAction,
|
|
264
335
|
};
|
|
265
336
|
}
|
|
@@ -320,3 +391,17 @@ export function workflow(
|
|
|
320
391
|
maybeHandler,
|
|
321
392
|
);
|
|
322
393
|
}
|
|
394
|
+
|
|
395
|
+
function normalizeWorkflowAuthProfile(
|
|
396
|
+
value: LibrettoWorkflowAuthProfile | undefined,
|
|
397
|
+
): { name: string; refresh?: boolean } | undefined {
|
|
398
|
+
if (!value) return undefined;
|
|
399
|
+
if (typeof value === "string") return { name: normalizeProfileName(value) };
|
|
400
|
+
const name = normalizeProfileName(value.name);
|
|
401
|
+
return {
|
|
402
|
+
name,
|
|
403
|
+
...(value.refresh === undefined
|
|
404
|
+
? {}
|
|
405
|
+
: { refresh: value.refresh }),
|
|
406
|
+
};
|
|
407
|
+
}
|