@pikku/core 0.12.9 → 0.12.10
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/CHANGELOG.md +7 -0
- package/dist/function/function-runner.d.ts +3 -1
- package/dist/function/function-runner.js +5 -1
- package/dist/services/credential-service.d.ts +40 -0
- package/dist/services/credential-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +13 -0
- package/dist/services/credential-wire-service.js +31 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.js +3 -0
- package/dist/services/local-credential-service.d.ts +10 -0
- package/dist/services/local-credential-service.js +33 -0
- package/dist/services/typed-credential-service.d.ts +27 -0
- package/dist/services/typed-credential-service.js +40 -0
- package/dist/testing/service-tests.d.ts +8 -0
- package/dist/testing/service-tests.js +106 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
- package/dist/wirings/credential/credential.types.d.ts +24 -0
- package/dist/wirings/credential/credential.types.js +1 -0
- package/dist/wirings/credential/index.d.ts +3 -0
- package/dist/wirings/credential/index.js +2 -0
- package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
- package/dist/wirings/credential/validate-credential-definitions.js +38 -0
- package/dist/wirings/credential/wire-credential.d.ts +48 -0
- package/dist/wirings/credential/wire-credential.js +47 -0
- package/dist/wirings/http/http-runner.js +4 -0
- package/dist/wirings/oauth2/index.d.ts +2 -1
- package/dist/wirings/oauth2/index.js +1 -1
- package/dist/wirings/oauth2/oauth2-client.js +4 -8
- package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
- package/dist/wirings/oauth2/oauth2-routes.js +146 -0
- package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
- package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +15 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +2 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +40 -8
- package/package.json +2 -1
- package/src/function/function-runner.ts +11 -0
- package/src/services/credential-service.ts +44 -0
- package/src/services/credential-wire-service.ts +44 -0
- package/src/services/index.ts +12 -0
- package/src/services/local-credential-service.ts +41 -0
- package/src/services/typed-credential-service.ts +75 -0
- package/src/testing/service-tests.ts +139 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
- package/src/wirings/credential/credential.types.ts +28 -0
- package/src/wirings/credential/index.ts +8 -0
- package/src/wirings/credential/validate-credential-definitions.ts +64 -0
- package/src/wirings/credential/wire-credential.ts +49 -0
- package/src/wirings/http/http-runner.ts +7 -0
- package/src/wirings/oauth2/index.ts +2 -1
- package/src/wirings/oauth2/oauth2-client.ts +4 -8
- package/src/wirings/oauth2/oauth2-routes.ts +234 -0
- package/src/wirings/oauth2/oauth2.types.ts +0 -27
- package/src/wirings/workflow/graph/graph-runner.ts +33 -1
- package/src/wirings/workflow/pikku-workflow-service.ts +55 -9
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
- package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
- package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
|
@@ -4,11 +4,95 @@ import { runWorkflowGraph } from '../workflow/graph/graph-runner.js';
|
|
|
4
4
|
import { ContextAwareRPCService, resolveNamespace } from '../rpc/rpc-runner.js';
|
|
5
5
|
import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
|
|
6
6
|
import { validateWorkflowWiring, computeEntryNodeIds, } from '../workflow/graph/graph-validation.js';
|
|
7
|
+
function formatSchemaType(schema, depth = 0) {
|
|
8
|
+
if (!schema)
|
|
9
|
+
return 'any';
|
|
10
|
+
if (schema.enum)
|
|
11
|
+
return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
12
|
+
if (schema.type === 'array') {
|
|
13
|
+
const itemType = schema.items
|
|
14
|
+
? formatSchemaType(schema.items, depth + 1)
|
|
15
|
+
: 'any';
|
|
16
|
+
return `${itemType}[]`;
|
|
17
|
+
}
|
|
18
|
+
if (schema.type === 'object' && schema.properties) {
|
|
19
|
+
if (depth > 1)
|
|
20
|
+
return 'object';
|
|
21
|
+
const fields = Object.entries(schema.properties)
|
|
22
|
+
.map(([k, v]) => `${k}: ${formatSchemaType(v, depth + 1)}`)
|
|
23
|
+
.join(', ');
|
|
24
|
+
return `{${fields}}`;
|
|
25
|
+
}
|
|
26
|
+
return schema.type || 'any';
|
|
27
|
+
}
|
|
28
|
+
function resolveToolMetaForWorkflow(toolName) {
|
|
29
|
+
const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null;
|
|
30
|
+
let fnMeta;
|
|
31
|
+
let schemas;
|
|
32
|
+
if (resolved) {
|
|
33
|
+
fnMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
|
|
34
|
+
schemas = pikkuState(resolved.package, 'misc', 'schemas');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const rpcMeta = pikkuState(null, 'rpc', 'meta');
|
|
38
|
+
const pikkuFuncId = rpcMeta[toolName];
|
|
39
|
+
if (!pikkuFuncId)
|
|
40
|
+
return null;
|
|
41
|
+
fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId];
|
|
42
|
+
schemas = pikkuState(null, 'misc', 'schemas');
|
|
43
|
+
}
|
|
44
|
+
if (!fnMeta)
|
|
45
|
+
return null;
|
|
46
|
+
const inputSchema = fnMeta.inputSchemaName
|
|
47
|
+
? schemas.get(fnMeta.inputSchemaName)
|
|
48
|
+
: null;
|
|
49
|
+
const outputSchema = fnMeta.outputSchemaName
|
|
50
|
+
? schemas.get(fnMeta.outputSchemaName)
|
|
51
|
+
: null;
|
|
52
|
+
return { fnMeta, inputSchema, outputSchema, schemas };
|
|
53
|
+
}
|
|
54
|
+
function buildDetailedToolSchemas(toolNames) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
for (const toolName of toolNames) {
|
|
57
|
+
const meta = resolveToolMetaForWorkflow(toolName);
|
|
58
|
+
if (!meta)
|
|
59
|
+
continue;
|
|
60
|
+
const { inputSchema, outputSchema } = meta;
|
|
61
|
+
const inputProps = inputSchema?.properties
|
|
62
|
+
? Object.entries(inputSchema.properties)
|
|
63
|
+
.map(([k, v]) => `${k}${inputSchema.required?.includes(k) ? '' : '?'}: ${formatSchemaType(v)}`)
|
|
64
|
+
.join(', ')
|
|
65
|
+
: '';
|
|
66
|
+
const outputPaths = outputSchema ? collectOutputPaths(outputSchema) : [];
|
|
67
|
+
lines.push(`**${toolName}**\n` +
|
|
68
|
+
` input: {${inputProps}}\n` +
|
|
69
|
+
(outputPaths.length > 0
|
|
70
|
+
? ` output paths: ${outputPaths.join(', ')}`
|
|
71
|
+
: ` output: void`));
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
function collectOutputPaths(schema, prefix = '') {
|
|
76
|
+
if (!schema?.properties)
|
|
77
|
+
return [];
|
|
78
|
+
const paths = [];
|
|
79
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
80
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
81
|
+
const type = prop.type || 'any';
|
|
82
|
+
if (prop.type === 'object' && prop.properties) {
|
|
83
|
+
paths.push(...collectOutputPaths(prop, path));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
paths.push(`${path}: ${type}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return paths;
|
|
90
|
+
}
|
|
7
91
|
export function buildDynamicWorkflowInstructions(tools, mode) {
|
|
8
92
|
if (mode === 'read') {
|
|
9
93
|
return ('\n\n## Existing Workflows\n\n' +
|
|
10
94
|
'You can list and execute previously saved workflows using listAgentWorkflows and executeAgentWorkflow.\n' +
|
|
11
|
-
|
|
95
|
+
"Use listAgentWorkflows to discover what's available, then executeAgentWorkflow to run them.");
|
|
12
96
|
}
|
|
13
97
|
const modeGuidance = mode === 'always'
|
|
14
98
|
? 'When a user requests a complex multi-step task, prefer creating a workflow over making sequential tool calls.\n' +
|
|
@@ -19,66 +103,94 @@ export function buildDynamicWorkflowInstructions(tools, mode) {
|
|
|
19
103
|
'can run in background) and wait for confirmation before creating.\n' +
|
|
20
104
|
'Do NOT create workflows automatically — always propose and get user approval first.\n' +
|
|
21
105
|
'For one-off tasks, just use the tools directly.\n\n';
|
|
22
|
-
const
|
|
106
|
+
const toolSummaryLines = [];
|
|
23
107
|
for (const toolName of tools) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null;
|
|
27
|
-
if (resolved) {
|
|
28
|
-
fnMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
|
|
29
|
-
schemas = pikkuState(resolved.package, 'misc', 'schemas');
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
const rpcMeta = pikkuState(null, 'rpc', 'meta');
|
|
33
|
-
const pikkuFuncId = rpcMeta[toolName];
|
|
34
|
-
if (!pikkuFuncId)
|
|
35
|
-
continue;
|
|
36
|
-
fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId];
|
|
37
|
-
schemas = pikkuState(null, 'misc', 'schemas');
|
|
38
|
-
}
|
|
39
|
-
if (!fnMeta)
|
|
108
|
+
const meta = resolveToolMetaForWorkflow(toolName);
|
|
109
|
+
if (!meta)
|
|
40
110
|
continue;
|
|
41
|
-
const inputSchema =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
? schemas.get(fnMeta.outputSchemaName)
|
|
46
|
-
: null;
|
|
47
|
-
const toolDescription = fnMeta.description || fnMeta.title || '';
|
|
48
|
-
const inputProps = inputSchema?.properties
|
|
49
|
-
? Object.entries(inputSchema.properties)
|
|
50
|
-
.map(([k, v]) => `${k}${inputSchema.required?.includes(k) ? '' : '?'}: ${v.type || 'any'}`)
|
|
51
|
-
.join(', ')
|
|
111
|
+
const { fnMeta, inputSchema, outputSchema } = meta;
|
|
112
|
+
const desc = fnMeta.description || fnMeta.title || '';
|
|
113
|
+
const inputFields = inputSchema?.properties
|
|
114
|
+
? Object.keys(inputSchema.properties).join(', ')
|
|
52
115
|
: '';
|
|
53
|
-
const
|
|
54
|
-
? Object.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
: 'any';
|
|
58
|
-
toolSchemaLines.push(`- ${toolName}(input: {${inputProps}}) → {${outputProps}}${toolDescription ? ` — ${toolDescription}` : ''}`);
|
|
116
|
+
const outputFields = outputSchema?.properties
|
|
117
|
+
? Object.keys(outputSchema.properties).join(', ')
|
|
118
|
+
: '';
|
|
119
|
+
toolSummaryLines.push(`| \`${toolName}\` | ${desc || '-'} | ${inputFields || '-'} | ${outputFields || '-'} |`);
|
|
59
120
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'###
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
121
|
+
const sections = [
|
|
122
|
+
'\n\n## Workflow Creation\n',
|
|
123
|
+
modeGuidance,
|
|
124
|
+
'### When to Create a Workflow\n' +
|
|
125
|
+
'Create a workflow when:\n' +
|
|
126
|
+
'- The task has 2+ steps with data dependencies between them\n' +
|
|
127
|
+
'- The task will be repeated (workflows are reusable)\n' +
|
|
128
|
+
'- Steps can fail independently and need error handling\n' +
|
|
129
|
+
'- Steps can run in parallel for better performance\n' +
|
|
130
|
+
'Do NOT create a workflow for single tool calls — just call the tool directly.\n',
|
|
131
|
+
'### Process\n' +
|
|
132
|
+
'1. Check if a suitable workflow exists: `listAgentWorkflows`\n' +
|
|
133
|
+
'2. If the request is too vague to build a reliable workflow, ask the user to be more specific. For example, if a user says "make a workflow that does stuff with my data" — ask them what steps they need and what data flows between them.\n' +
|
|
134
|
+
'3. Plan the graph: identify steps, dependencies, parallel paths, and failure modes\n' +
|
|
135
|
+
'4. Create and validate: `createAgentWorkflow` — if validation fails, fix the errors and retry\n' +
|
|
136
|
+
'5. Present the workflow to the user and get approval\n' +
|
|
137
|
+
'6. Activate: `saveAgentWorkflow`\n' +
|
|
138
|
+
'7. Run: `executeAgentWorkflow`\n',
|
|
139
|
+
'### Available Tools\n' +
|
|
140
|
+
'| Tool | Description | Input fields | Output fields |\n' +
|
|
141
|
+
'|------|-------------|-------------|---------------|\n' +
|
|
142
|
+
toolSummaryLines.join('\n') +
|
|
143
|
+
'\n\n' +
|
|
144
|
+
'Full schemas (input types, output paths) are returned by `createAgentWorkflow` after validation.\n',
|
|
145
|
+
'### Graph Format Reference\n\n' +
|
|
146
|
+
'**Node:** `{ rpcName, input?, next?, onError? }`\n\n' +
|
|
147
|
+
'**Data wiring (input field values):**\n' +
|
|
148
|
+
'- `{"$ref": "trigger", "path": "fieldName"}` — extract field from trigger input (ALWAYS include `path`)\n' +
|
|
149
|
+
'- `{"$ref": "nodeId", "path": "fieldName"}` — extract field from a previous node\'s output (ALWAYS include `path`)\n' +
|
|
150
|
+
'- `path` supports dot notation for nested fields — use the "output paths" listed above to find the correct path\n' +
|
|
151
|
+
'- Example: if a tool\'s output paths show `todo.id: string`, use `{"$ref": "nodeId", "path": "todo.id"}`\n' +
|
|
152
|
+
'- `{"$template": {"parts": ["Hello ", "!"], "expressions": [{"$ref": "trigger", "path": "name"}]}}` — string interpolation\n' +
|
|
153
|
+
'- Static values passed directly: `{"count": 10}`\n\n' +
|
|
154
|
+
'**Flow control (`next`):**\n' +
|
|
155
|
+
'- `"next": "b"` — sequential\n' +
|
|
156
|
+
'- `"next": ["b", "c"]` — parallel fan-out\n' +
|
|
157
|
+
'- `"next": {"ok": "b", "fail": "c"}` — conditional branch (node calls `graph.branch("ok")` at runtime)\n' +
|
|
158
|
+
'- Omit `next` for terminal nodes. Entry nodes are auto-detected.\n\n' +
|
|
159
|
+
'**Error handling:**\n' +
|
|
160
|
+
'- `"onError": "handler"` — route to error handler on failure\n' +
|
|
161
|
+
'- Error handler receives `{ error: { message: string } }` as input\n' +
|
|
162
|
+
'- Without `onError`, a node failure fails the entire workflow\n\n' +
|
|
163
|
+
'**Sub-workflows:** if `rpcName` matches an existing workflow name, it runs as a child workflow with its own execution context.\n\n' +
|
|
164
|
+
'**Trigger input:** the workflow receives input when executed. Entry nodes extract fields via `$ref: "trigger"`. Document what fields your workflow expects in the `description`.\n',
|
|
165
|
+
'### Design Principles\n' +
|
|
166
|
+
'1. **Map dependencies first** — before writing nodes, identify which steps depend on which outputs\n' +
|
|
167
|
+
'2. **Parallelize independent work** — steps with no data dependency should fan out via array `next`\n' +
|
|
168
|
+
'3. **Add error handlers for external calls** — any node calling an external service should have `onError`\n' +
|
|
169
|
+
"4. **Minimize blast radius** — isolate risky nodes so their failure doesn't cascade\n" +
|
|
170
|
+
'5. **Name nodes by intent** — `fetchUsers`, `sendAlert` not `step1`, `step2`\n' +
|
|
171
|
+
'6. **Keep graphs shallow** — prefer wide parallel graphs over deep sequential chains when possible\n',
|
|
172
|
+
'### Common Mistakes\n' +
|
|
173
|
+
'- `{"$ref": "trigger"}` without `path` — passes entire object, use `{"$ref": "trigger", "path": "field"}`\n' +
|
|
174
|
+
'- Wrong path depth: if output paths show `todo.id`, use `"path": "todo.id"` not `"path": "id"`\n' +
|
|
175
|
+
'- Type mismatch: wiring a string output to a number input\n' +
|
|
176
|
+
'- Dangling `onError`/`next` references to nodes not in the graph\n' +
|
|
177
|
+
'- Cycles: if A→B→A, there are no entry nodes\n',
|
|
178
|
+
'### Examples\n\n' +
|
|
179
|
+
'**Sequential with nested output path (if addTodo output paths show `todo.id`):**\n' +
|
|
180
|
+
'```json\n{"add":{"rpcName":"addTodo","input":{"title":{"$ref":"trigger","path":"title"}},"next":"complete"},"complete":{"rpcName":"completeTodo","input":{"id":{"$ref":"add","path":"todo.id"}}}}\n```\n\n' +
|
|
181
|
+
'**Error handling:**\n' +
|
|
182
|
+
'```json\n{"deploy":{"rpcName":"deploy","input":{"env":{"$ref":"trigger","path":"env"}},"next":"notify","onError":"rollback"},"notify":{"rpcName":"alert","input":{"message":"Deployed"}},"rollback":{"rpcName":"rollback","input":{},"next":"alertFail"},"alertFail":{"rpcName":"alert","input":{"message":"Rolled back"}}}\n```\n\n' +
|
|
183
|
+
'**Parallel fan-out:**\n' +
|
|
184
|
+
'```json\n{"fetchA":{"rpcName":"getA","input":{},"next":"combine"},"fetchB":{"rpcName":"getB","input":{},"next":"combine"},"combine":{"rpcName":"graph:merge","input":{"items":[{"$ref":"fetchA","path":"data"},{"$ref":"fetchB","path":"data"}]}}}\n```',
|
|
185
|
+
];
|
|
186
|
+
return sections.join('\n');
|
|
75
187
|
}
|
|
76
188
|
export function buildWorkflowTools(agentName, packageName, toolNames, mode, streamContext, sessionService) {
|
|
77
189
|
const tools = [];
|
|
78
190
|
if (mode !== 'read') {
|
|
79
191
|
tools.push({
|
|
80
192
|
name: 'createAgentWorkflow',
|
|
81
|
-
description: 'Validate and create a draft workflow
|
|
193
|
+
description: 'Validate and create a draft workflow DAG that chains tools together. The graph is validated for wiring correctness and type compatibility. Call saveAgentWorkflow to activate it after the user approves.',
|
|
82
194
|
inputSchema: {
|
|
83
195
|
type: 'object',
|
|
84
196
|
properties: {
|
|
@@ -92,7 +204,7 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
|
|
|
92
204
|
},
|
|
93
205
|
nodes: {
|
|
94
206
|
type: 'string',
|
|
95
|
-
description: 'JSON string of
|
|
207
|
+
description: 'JSON string of nodeId→node map. Node fields: rpcName (tool name), input (use $ref+path to wire data), next (flow control), onError (error routing). Must have 2+ nodes.',
|
|
96
208
|
},
|
|
97
209
|
},
|
|
98
210
|
required: ['name', 'nodes'],
|
|
@@ -117,11 +229,18 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
|
|
|
117
229
|
error: 'A workflow must have at least 2 nodes. A single node is just a tool call — use the tool directly instead.',
|
|
118
230
|
};
|
|
119
231
|
}
|
|
232
|
+
const usedTools = [
|
|
233
|
+
...new Set(Object.values(nodes)
|
|
234
|
+
.map((n) => n.rpcName)
|
|
235
|
+
.filter(Boolean)),
|
|
236
|
+
];
|
|
237
|
+
const toolSchemas = buildDetailedToolSchemas(usedTools);
|
|
120
238
|
const validationErrors = validateWorkflowWiring(nodes, toolNames);
|
|
121
239
|
if (validationErrors.length > 0) {
|
|
122
240
|
return {
|
|
123
241
|
error: 'Workflow validation failed',
|
|
124
242
|
errors: validationErrors,
|
|
243
|
+
toolSchemas,
|
|
125
244
|
};
|
|
126
245
|
}
|
|
127
246
|
const entryNodeIds = computeEntryNodeIds(nodes);
|
|
@@ -152,7 +271,8 @@ export function buildWorkflowTools(agentName, packageName, toolNames, mode, stre
|
|
|
152
271
|
graphHash,
|
|
153
272
|
entryNodes: entryNodeIds,
|
|
154
273
|
nodeCount: Object.keys(nodes).length,
|
|
155
|
-
|
|
274
|
+
toolSchemas,
|
|
275
|
+
message: `Workflow '${fullName}' validated and saved as draft. Check the toolSchemas to verify your $ref paths match the output paths. Then present this to the user and call saveAgentWorkflow to activate it.`,
|
|
156
276
|
};
|
|
157
277
|
},
|
|
158
278
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OAuth2CredentialConfig } from '../secret/secret.types.js';
|
|
2
|
+
export type CoreCredential<T = unknown> = {
|
|
3
|
+
name: string;
|
|
4
|
+
displayName: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
type: 'singleton' | 'wire';
|
|
7
|
+
schema: T;
|
|
8
|
+
oauth2?: OAuth2CredentialConfig & {
|
|
9
|
+
appCredentialSecretId: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export type CredentialDefinitionMeta = {
|
|
13
|
+
name: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
type: 'singleton' | 'wire';
|
|
17
|
+
schema?: Record<string, unknown> | string;
|
|
18
|
+
oauth2?: OAuth2CredentialConfig & {
|
|
19
|
+
appCredentialSecretId: string;
|
|
20
|
+
};
|
|
21
|
+
sourceFile?: string;
|
|
22
|
+
};
|
|
23
|
+
export type CredentialDefinitionsMeta = Record<string, CredentialDefinitionMeta>;
|
|
24
|
+
export type CredentialDefinitions = CredentialDefinitionMeta[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { wireCredential } from './wire-credential.js';
|
|
2
|
+
export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js';
|
|
3
|
+
export type { CoreCredential, CredentialDefinitionMeta, CredentialDefinitionsMeta, CredentialDefinitions, } from './credential.types.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CredentialDefinitions, CredentialDefinitionsMeta } from './credential.types.js';
|
|
2
|
+
export interface SchemaRefLike {
|
|
3
|
+
variableName: string;
|
|
4
|
+
sourceFile: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function validateAndBuildCredentialDefinitionsMeta(definitions: CredentialDefinitions, schemaLookup: Map<string, SchemaRefLike>): CredentialDefinitionsMeta;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function validateAndBuildCredentialDefinitionsMeta(definitions, schemaLookup) {
|
|
2
|
+
const meta = {};
|
|
3
|
+
for (const def of definitions) {
|
|
4
|
+
const existing = meta[def.name];
|
|
5
|
+
if (existing) {
|
|
6
|
+
if (def.schema && existing.schema) {
|
|
7
|
+
const defSchemaRef = schemaLookup.get(def.schema);
|
|
8
|
+
const existingSchemaRef = schemaLookup.get(existing.schema);
|
|
9
|
+
if (defSchemaRef && existingSchemaRef) {
|
|
10
|
+
if (defSchemaRef.variableName !== existingSchemaRef.variableName ||
|
|
11
|
+
defSchemaRef.sourceFile !== existingSchemaRef.sourceFile) {
|
|
12
|
+
throw new Error(`Credential '${def.name}' is defined with different schemas.\n` +
|
|
13
|
+
` First definition: ${existing.sourceFile} (schema: ${existingSchemaRef.variableName})\n` +
|
|
14
|
+
` Second definition: ${def.sourceFile} (schema: ${defSchemaRef.variableName})\n` +
|
|
15
|
+
`Credentials sharing a name must use the same schema.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (def.type !== existing.type) {
|
|
20
|
+
throw new Error(`Credential '${def.name}' is defined with different types.\n` +
|
|
21
|
+
` First definition: ${existing.sourceFile} (type: ${existing.type})\n` +
|
|
22
|
+
` Second definition: ${def.sourceFile} (type: ${def.type})\n` +
|
|
23
|
+
`Credentials sharing a name must use the same type.`);
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
meta[def.name] = {
|
|
28
|
+
name: def.name,
|
|
29
|
+
displayName: def.displayName,
|
|
30
|
+
description: def.description,
|
|
31
|
+
type: def.type,
|
|
32
|
+
schema: def.schema,
|
|
33
|
+
oauth2: def.oauth2,
|
|
34
|
+
sourceFile: def.sourceFile,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return meta;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CoreCredential } from './credential.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* No-op function for declaring credentials.
|
|
4
|
+
* This exists purely for TypeScript type checking and will be tree-shaken.
|
|
5
|
+
* The CLI extracts metadata via AST parsing.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // Per-user API key
|
|
10
|
+
* wireCredential({
|
|
11
|
+
* name: 'stripe',
|
|
12
|
+
* displayName: 'Stripe API Key',
|
|
13
|
+
* type: 'wire',
|
|
14
|
+
* schema: z.object({ apiKey: z.string() }),
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* // Per-user OAuth
|
|
18
|
+
* wireCredential({
|
|
19
|
+
* name: 'google-sheets',
|
|
20
|
+
* displayName: 'Google Sheets',
|
|
21
|
+
* type: 'wire',
|
|
22
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
23
|
+
* oauth2: {
|
|
24
|
+
* appCredentialSecretId: 'GOOGLE_OAUTH_APP',
|
|
25
|
+
* authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
26
|
+
* tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
27
|
+
* scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
|
28
|
+
* tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
|
|
29
|
+
* }
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* // Platform-level OAuth (singleton)
|
|
33
|
+
* wireCredential({
|
|
34
|
+
* name: 'slack',
|
|
35
|
+
* displayName: 'Slack',
|
|
36
|
+
* type: 'singleton',
|
|
37
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
38
|
+
* oauth2: {
|
|
39
|
+
* appCredentialSecretId: 'SLACK_OAUTH_APP',
|
|
40
|
+
* authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
|
41
|
+
* tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
|
42
|
+
* scopes: ['chat:write', 'channels:read'],
|
|
43
|
+
* tokenSecretId: 'SLACK_OAUTH_TOKENS',
|
|
44
|
+
* }
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare const wireCredential: <T>(_config: CoreCredential<T>) => void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-op function for declaring credentials.
|
|
3
|
+
* This exists purely for TypeScript type checking and will be tree-shaken.
|
|
4
|
+
* The CLI extracts metadata via AST parsing.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // Per-user API key
|
|
9
|
+
* wireCredential({
|
|
10
|
+
* name: 'stripe',
|
|
11
|
+
* displayName: 'Stripe API Key',
|
|
12
|
+
* type: 'wire',
|
|
13
|
+
* schema: z.object({ apiKey: z.string() }),
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Per-user OAuth
|
|
17
|
+
* wireCredential({
|
|
18
|
+
* name: 'google-sheets',
|
|
19
|
+
* displayName: 'Google Sheets',
|
|
20
|
+
* type: 'wire',
|
|
21
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
22
|
+
* oauth2: {
|
|
23
|
+
* appCredentialSecretId: 'GOOGLE_OAUTH_APP',
|
|
24
|
+
* authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
25
|
+
* tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
26
|
+
* scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
|
27
|
+
* tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
|
|
28
|
+
* }
|
|
29
|
+
* })
|
|
30
|
+
*
|
|
31
|
+
* // Platform-level OAuth (singleton)
|
|
32
|
+
* wireCredential({
|
|
33
|
+
* name: 'slack',
|
|
34
|
+
* displayName: 'Slack',
|
|
35
|
+
* type: 'singleton',
|
|
36
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
37
|
+
* oauth2: {
|
|
38
|
+
* appCredentialSecretId: 'SLACK_OAUTH_APP',
|
|
39
|
+
* authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
|
40
|
+
* tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
|
41
|
+
* scopes: ['chat:write', 'channels:read'],
|
|
42
|
+
* tokenSecretId: 'SLACK_OAUTH_TOKENS',
|
|
43
|
+
* }
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const wireCredential = (_config) => { };
|
|
@@ -3,6 +3,7 @@ import { closeWireServices, createWeakUID, isSerializable, } from '../../utils.j
|
|
|
3
3
|
import { getSingletonServices, getCreateWireServices, } from '../../pikku-state.js';
|
|
4
4
|
import { PikkuSessionService } from '../../services/user-session-service.js';
|
|
5
5
|
import { getErrorResponse } from '../../errors/error-handler.js';
|
|
6
|
+
import { PikkuCredentialWireService, createMiddlewareCredentialWireProps, } from '../../services/credential-wire-service.js';
|
|
6
7
|
import { handleHTTPError } from '../../handle-error.js';
|
|
7
8
|
import { pikkuState } from '../../pikku-state.js';
|
|
8
9
|
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
@@ -197,6 +198,7 @@ export const createHTTPWire = (request, response) => {
|
|
|
197
198
|
*/
|
|
198
199
|
const executeRoute = async (services, matchedRoute, http, options) => {
|
|
199
200
|
const userSession = new PikkuSessionService();
|
|
201
|
+
const credentialWire = new PikkuCredentialWireService();
|
|
200
202
|
const { params, route, meta } = matchedRoute;
|
|
201
203
|
const { singletonServices, createWireServices, skipUserSession, requestId } = services;
|
|
202
204
|
// Attach URL parameters to the request object
|
|
@@ -257,6 +259,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
257
259
|
setSession: (s) => userSession.setInitial(s),
|
|
258
260
|
getSession: () => userSession.get(),
|
|
259
261
|
hasSessionChanged: () => userSession.sessionChanged,
|
|
262
|
+
...createMiddlewareCredentialWireProps(credentialWire),
|
|
260
263
|
};
|
|
261
264
|
result = await runPikkuFunc('http', `${meta.method}:${meta.route}`, meta.pikkuFuncId, {
|
|
262
265
|
singletonServices,
|
|
@@ -271,6 +274,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
271
274
|
tags: route.tags,
|
|
272
275
|
wire,
|
|
273
276
|
sessionService: userSession,
|
|
277
|
+
credentialWireService: credentialWire,
|
|
274
278
|
packageName: meta.packageName,
|
|
275
279
|
});
|
|
276
280
|
if (!matchedRoute.route.sse) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { OAuth2Client } from './oauth2-client.js';
|
|
2
|
-
export {
|
|
2
|
+
export { createOAuth2Handler } from './oauth2-routes.js';
|
|
3
|
+
export type { CreateOAuth2HandlerOptions } from './oauth2-routes.js';
|
|
3
4
|
export type { OAuth2AppCredential, OAuth2Token } from './oauth2.types.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { OAuth2Client } from './oauth2-client.js';
|
|
2
|
-
export {
|
|
2
|
+
export { createOAuth2Handler } from './oauth2-routes.js';
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* })
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
const DEFAULT_TIMEOUT_MS =
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
30
|
+
const TOKEN_EXPIRY_BUFFER_MS = 60_000;
|
|
30
31
|
/**
|
|
31
32
|
* Helper to fetch with a timeout using AbortController.
|
|
32
33
|
*/
|
|
@@ -114,16 +115,13 @@ export class OAuth2Client {
|
|
|
114
115
|
* Uses a promise lock to prevent concurrent refresh attempts.
|
|
115
116
|
*/
|
|
116
117
|
async refreshAndGetToken() {
|
|
117
|
-
// If already refreshing, wait for that to complete
|
|
118
118
|
if (this.refreshPromise) {
|
|
119
119
|
const token = await this.refreshPromise;
|
|
120
120
|
return token.accessToken;
|
|
121
121
|
}
|
|
122
|
-
// Start refresh
|
|
123
122
|
this.refreshPromise = this.doRefreshToken();
|
|
124
123
|
try {
|
|
125
124
|
const token = await this.refreshPromise;
|
|
126
|
-
this.cachedToken = token;
|
|
127
125
|
return token.accessToken;
|
|
128
126
|
}
|
|
129
127
|
finally {
|
|
@@ -169,9 +167,8 @@ export class OAuth2Client {
|
|
|
169
167
|
tokenType: data.token_type || 'Bearer',
|
|
170
168
|
scope: data.scope,
|
|
171
169
|
};
|
|
172
|
-
// Cache and persist the refreshed token
|
|
173
|
-
this.cachedToken = token;
|
|
174
170
|
await this.secrets.setSecretJSON(this.oauth2Config.tokenSecretId, token);
|
|
171
|
+
this.cachedToken = token;
|
|
175
172
|
return token;
|
|
176
173
|
}
|
|
177
174
|
/**
|
|
@@ -181,8 +178,7 @@ export class OAuth2Client {
|
|
|
181
178
|
if (!token.expiresAt) {
|
|
182
179
|
return true; // No expiry info, assume valid
|
|
183
180
|
}
|
|
184
|
-
|
|
185
|
-
return token.expiresAt > Date.now() + 60000;
|
|
181
|
+
return token.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
|
186
182
|
}
|
|
187
183
|
/**
|
|
188
184
|
* Get app credentials (cached).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CredentialDefinitionsMeta } from '../credential/credential.types.js';
|
|
2
|
+
export type CreateOAuth2HandlerOptions = {
|
|
3
|
+
credentialsMeta: CredentialDefinitionsMeta;
|
|
4
|
+
basePath?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Creates OAuth2 route handlers for user credential management.
|
|
8
|
+
*
|
|
9
|
+
* Returns individual handler functions for connect/callback/disconnect/status
|
|
10
|
+
* that handle the OAuth2 authorization code flow and store tokens in CredentialService.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const oauth2 = createOAuth2Handler({ credentialsMeta })
|
|
15
|
+
*
|
|
16
|
+
* const oauth2Routes = defineHTTPRoutes({
|
|
17
|
+
* auth: true,
|
|
18
|
+
* basePath: '/credentials',
|
|
19
|
+
* routes: {
|
|
20
|
+
* connect: { method: 'get', route: '/:name/connect', func: oauth2.connect },
|
|
21
|
+
* callback: { method: 'get', route: '/:name/callback', func: oauth2.callback, auth: false },
|
|
22
|
+
* disconnect: { method: 'delete', route: '/:name', func: oauth2.disconnect },
|
|
23
|
+
* status: { method: 'get', route: '/:name/status', func: oauth2.status },
|
|
24
|
+
* },
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* wireHTTPRoutes({ routes: { credentials: oauth2Routes } })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare const createOAuth2Handler: (options: CreateOAuth2HandlerOptions) => {
|
|
31
|
+
connect: any;
|
|
32
|
+
callback: any;
|
|
33
|
+
disconnect: any;
|
|
34
|
+
status: any;
|
|
35
|
+
};
|