@pikku/core 0.12.91 → 0.12.93
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 +102 -0
- package/dist/errors/error-handler.d.ts +11 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/errors/serialized-error.d.ts +3 -0
- package/dist/function/function-runner.d.ts +1 -1
- package/dist/function/function-runner.js +3 -3
- package/dist/function/functions.types.d.ts +33 -21
- package/dist/middleware/auth-apikey.d.ts +7 -0
- package/dist/middleware/auth-apikey.js +6 -0
- package/dist/middleware/auth-bearer.d.ts +3 -0
- package/dist/middleware/auth-bearer.js +2 -0
- package/dist/middleware/auth-cookie.d.ts +9 -0
- package/dist/middleware/auth-cookie.js +6 -4
- package/dist/middleware/cors.d.ts +8 -0
- package/dist/middleware/cors.js +2 -0
- package/dist/middleware/middleware-factories.d.ts +14 -0
- package/dist/middleware/middleware-factories.js +14 -0
- package/dist/services/local-secrets.js +1 -1
- package/dist/services/persona-sign-in.d.ts +0 -2
- package/dist/services/persona-sign-in.js +15 -66
- package/dist/services/personas-service.d.ts +4 -0
- package/dist/services/personas-service.js +4 -0
- package/dist/services/secret-service.d.ts +0 -1
- package/dist/testing/service-tests/secret-service-tests.js +1 -1
- package/dist/types/core.types.d.ts +5 -0
- package/dist/wirings/addon/wire-addon.d.ts +17 -15
- package/dist/wirings/addon/wire-addon.js +6 -0
- package/dist/wirings/addon/wire-remote-addon.d.ts +4 -0
- package/dist/wirings/addon/wire-remote-addon.js +4 -0
- package/dist/wirings/agent/agent-prepare.js +2 -2
- package/dist/wirings/agent/agent.types.d.ts +31 -27
- package/dist/wirings/agent-scorer/agent-scorer.d.ts +4 -0
- package/dist/wirings/agent-scorer/agent-scorer.js +4 -0
- package/dist/wirings/channel/channel.types.d.ts +11 -0
- package/dist/wirings/cli/cli.types.d.ts +43 -1
- package/dist/wirings/cli/define-cli-commands.d.ts +6 -1
- package/dist/wirings/cli/define-cli-commands.js +6 -1
- package/dist/wirings/credential/credential.types.d.ts +7 -0
- package/dist/wirings/gateway/gateway.types.d.ts +23 -0
- package/dist/wirings/http/http.types.d.ts +51 -37
- package/dist/wirings/mcp/mcp.types.d.ts +27 -0
- package/dist/wirings/queue/queue.types.d.ts +6 -0
- package/dist/wirings/scheduler/scheduler.types.d.ts +5 -0
- package/dist/wirings/secret/secret.types.d.ts +9 -14
- package/dist/wirings/secret/secret.types.js +7 -0
- package/dist/wirings/trigger/trigger.types.d.ts +4 -1
- package/dist/wirings/variable/variable.types.d.ts +14 -13
- package/dist/wirings/variable/variable.types.js +7 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +7 -0
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +6 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +6 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +6 -0
- package/dist/wirings/workflow/scenario-poll.js +6 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +6 -0
- package/dist/wirings/workflow/scenario-step-guards.js +6 -0
- package/dist/wirings/workflow/workflow-errors.d.ts +4 -0
- package/dist/wirings/workflow/workflow-errors.js +4 -0
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/errors/error-handler.ts +11 -0
- package/src/errors/serialized-error.ts +3 -0
- package/src/function/function-runner.ts +4 -4
- package/src/function/functions.types.ts +33 -21
- package/src/middleware/auth-apikey.ts +7 -0
- package/src/middleware/auth-bearer.ts +3 -0
- package/src/middleware/auth-cookie.ts +9 -4
- package/src/middleware/cors.ts +8 -0
- package/src/middleware/middleware-factories.ts +14 -0
- package/src/services/local-secrets.test.ts +2 -2
- package/src/services/local-secrets.ts +1 -1
- package/src/services/persona-sign-in.test.ts +28 -34
- package/src/services/persona-sign-in.ts +16 -88
- package/src/services/personas-service.ts +4 -0
- package/src/services/secret-service.ts +0 -1
- package/src/testing/service-tests/secret-service-tests.ts +1 -1
- package/src/types/core.types.ts +5 -0
- package/src/wirings/addon/addon-auth-tags.test.ts +4 -4
- package/src/wirings/addon/wire-addon.ts +17 -15
- package/src/wirings/addon/wire-remote-addon.ts +4 -0
- package/src/wirings/agent/agent-authorization.test.ts +7 -3
- package/src/wirings/agent/agent-prepare.ts +2 -2
- package/src/wirings/agent/agent.types.ts +31 -27
- package/src/wirings/agent-scorer/agent-scorer.ts +4 -0
- package/src/wirings/channel/channel.types.ts +11 -0
- package/src/wirings/cli/cli.types.ts +44 -1
- package/src/wirings/cli/define-cli-commands.ts +6 -1
- package/src/wirings/credential/credential.types.ts +7 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +3 -3
- package/src/wirings/gateway/gateway.types.ts +23 -0
- package/src/wirings/http/http-runner-addon-ref.test.ts +1 -1
- package/src/wirings/http/http.types.ts +94 -69
- package/src/wirings/mcp/mcp.types.ts +27 -0
- package/src/wirings/queue/queue.types.ts +6 -0
- package/src/wirings/scheduler/scheduler.types.ts +5 -0
- package/src/wirings/secret/secret.types.ts +9 -14
- package/src/wirings/trigger/trigger.types.ts +4 -1
- package/src/wirings/variable/variable.types.ts +14 -13
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +6 -0
- package/src/wirings/workflow/scenario-poll.ts +6 -0
- package/src/wirings/workflow/scenario-step-guards.ts +6 -0
- package/src/wirings/workflow/workflow-errors.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -193,30 +193,38 @@ export type CorePikkuFunctionConfig<
|
|
|
193
193
|
OutputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
194
194
|
Scope extends string = string,
|
|
195
195
|
> = {
|
|
196
|
+
/** A human name for this function, shown wherever it is listed rather than called. */
|
|
196
197
|
title?: string
|
|
198
|
+
/** What the function does. An agent choosing between tools reads this, so it is worth more care than a comment would be. */
|
|
197
199
|
description?: string
|
|
198
200
|
/** Explicit logical name override; lets multiple exports share a versioned base */
|
|
199
201
|
override?: string
|
|
202
|
+
/** Which version of this contract this export is. Two exports sharing an `override` and differing here are the same function at two versions. */
|
|
200
203
|
version?: number
|
|
204
|
+
/** Filters this function in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
|
|
201
205
|
tags?: string[]
|
|
206
|
+
/** Makes the function callable from outside as `POST /rpc/<name>`. Without a session requirement, a permission or an addon gate, that means callable by anyone. */
|
|
202
207
|
expose?: boolean
|
|
203
208
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* A last resort. Prefer `permissions`, which are declared, inspectable, and
|
|
209
|
-
* reusable; reach for this only when the check cannot be expressed as one.
|
|
210
|
-
*
|
|
211
|
-
* Purely declarative — it grants nothing, and asserting it falsely disables
|
|
212
|
-
* the audit that would have caught the mistake. Requires
|
|
213
|
-
* `allow.permissionsInBody` in `pikku.config.json`.
|
|
209
|
+
* Declares that the body does its own permission check, so the function is
|
|
210
|
+
* not open despite naming no session, scope or permission. It grants
|
|
211
|
+
* nothing — asserting it falsely just disables the audit that would have
|
|
212
|
+
* caught the mistake. Requires `allow.permissionsInBody` in the config.
|
|
214
213
|
*/
|
|
215
214
|
permissionsInBody?: boolean
|
|
215
|
+
/** Publishes the function in this package's remote surface, which is what a `wireRemoteAddon` consumer gets a typed client for. */
|
|
216
216
|
remote?: boolean
|
|
217
|
+
/** Offers the function to MCP clients as a tool, without a separate `wireMCPTool`. */
|
|
217
218
|
mcp?: boolean
|
|
219
|
+
/**
|
|
220
|
+
* Declares that the function only reads. It is enforced rather than
|
|
221
|
+
* decorative: a read-only session is refused any function without it, and an
|
|
222
|
+
* agent may call one without asking permission first.
|
|
223
|
+
*/
|
|
218
224
|
readonly?: boolean
|
|
225
|
+
/** Where this function is deployed when the build can go either way. `auto` lets the analyser decide from what the function touches. */
|
|
219
226
|
deploy?: 'serverless' | 'server' | 'auto'
|
|
227
|
+
/** Under an agent's `explicit` approval policy, calling this pauses for a human to approve it. */
|
|
220
228
|
approvalRequired?: boolean
|
|
221
229
|
/** When true, workflow steps calling this function are dispatched via the queue. No queue service configured is a hard error. Defaults to false (inline). */
|
|
222
230
|
workflowQueued?: boolean
|
|
@@ -230,18 +238,21 @@ export type CorePikkuFunctionConfig<
|
|
|
230
238
|
* and report how much of the flow each surface actually covers.
|
|
231
239
|
*/
|
|
232
240
|
surfaces?: ScenarioSurface[]
|
|
241
|
+
/** Scenario steps only, and set by the definer rather than by hand: this step needs a persona, so the runner injects `wire.actor`. */
|
|
242
|
+
requiresActor?: boolean
|
|
233
243
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
244
|
+
* Records every call in the audit log. `transactional` durability writes the
|
|
245
|
+
* entry in the same transaction as the work, so the two cannot disagree;
|
|
246
|
+
* `best-effort` does not hold the request up for it.
|
|
237
247
|
*/
|
|
238
|
-
requiresActor?: boolean
|
|
239
248
|
audit?:
|
|
240
249
|
| boolean
|
|
241
250
|
| {
|
|
242
251
|
durability?: 'best-effort' | 'transactional'
|
|
243
252
|
}
|
|
253
|
+
/** Builds the sentence a human is shown when asked to approve a call, from that call's own input. */
|
|
244
254
|
approvalDescription?: any
|
|
255
|
+
/** The body. Its first parameter is the services it needs, destructured inline so the build can tree-shake the rest away. */
|
|
245
256
|
func: PikkuFunction
|
|
246
257
|
/**
|
|
247
258
|
* Scenarios only: runs before the scenario body, with the scenario's own
|
|
@@ -260,20 +271,21 @@ export type CorePikkuFunctionConfig<
|
|
|
260
271
|
* as skipped rather than quietly omitted; naming it in `--flows` runs it.
|
|
261
272
|
*/
|
|
262
273
|
skip?: string
|
|
274
|
+
/** Whether calling this requires a session, wherever it is wired. A wiring can be more permissive than the function, never less. */
|
|
263
275
|
auth?: boolean
|
|
264
|
-
/**
|
|
265
|
-
* Scopes the session must hold; all are required (AND) and checked before
|
|
266
|
-
* `permissions`, which OR together — a scope can only narrow access.
|
|
267
|
-
* Narrowed to the generated `ScopeId` union, so an undeclared scope is a
|
|
268
|
-
* compile error. Requires a session — see
|
|
269
|
-
* {@link CorePikkuSessionlessFunctionConfig}.
|
|
270
|
-
*/
|
|
276
|
+
/** Scopes the session must hold. All are required, and checked before `permissions`, which OR together — a scope only narrows access. */
|
|
271
277
|
scopes?: Scope[]
|
|
278
|
+
/** Checks that run before the body. Grouped names OR together, so any one passing admits the caller; use `scopes` to require rather than offer. */
|
|
272
279
|
permissions?: CorePermissionGroup<PikkuPermission>
|
|
280
|
+
/** Wraps this function wherever it is called from, unlike wiring middleware which only wraps one route into it. */
|
|
273
281
|
middleware?: PikkuMiddleware[]
|
|
282
|
+
/** The input schema, which is also the input type — there is no separate generic to keep in step with it. */
|
|
274
283
|
input?: InputSchema
|
|
284
|
+
/** The output schema, which is also the return type. Naming a type here instead is what produces PKU463. */
|
|
275
285
|
output?: OutputSchema
|
|
286
|
+
/** Presentation for this function as a node in a workflow graph. */
|
|
276
287
|
node?: CoreNodeConfig
|
|
288
|
+
/** Error classes this may throw, so each one's registered HTTP status is used instead of a 500. */
|
|
277
289
|
errors?: Array<typeof PikkuError>
|
|
278
290
|
}
|
|
279
291
|
|
|
@@ -2,7 +2,14 @@ import {
|
|
|
2
2
|
pikkuMiddleware,
|
|
3
3
|
pikkuMiddlewareFactory,
|
|
4
4
|
} from './middleware-factories.js'
|
|
5
|
+
/**
|
|
6
|
+
* Reads an API key from the request and JWT-decodes it into a session. Leaves
|
|
7
|
+
* an existing session alone, so it composes with other auth middleware.
|
|
8
|
+
*
|
|
9
|
+
* @example snippet: machineAuth
|
|
10
|
+
*/
|
|
5
11
|
export const authAPIKey = pikkuMiddlewareFactory<{
|
|
12
|
+
/** Where to look: the `x-api-key` header, the `apiKey` query param, or both. */
|
|
6
13
|
source: 'header' | 'query' | 'all'
|
|
7
14
|
}>(({ source }) =>
|
|
8
15
|
pikkuMiddleware(
|
|
@@ -17,8 +17,11 @@ const constantTimeEqual = (a: string, b: string): boolean => {
|
|
|
17
17
|
* Validates a bearer token: JWT-decoded by default, or compared in constant
|
|
18
18
|
* time against a static `value` or a `secretId` resolved through the secrets
|
|
19
19
|
* service per request.
|
|
20
|
+
*
|
|
21
|
+
* @example snippet: machineAuth
|
|
20
22
|
*/
|
|
21
23
|
export const authBearer = pikkuMiddlewareFactory<{
|
|
24
|
+
/** Omit to JWT-decode the token. Set it to accept one fixed token instead, matched in constant time. */
|
|
22
25
|
token?:
|
|
23
26
|
| {
|
|
24
27
|
value: string
|
|
@@ -6,10 +6,6 @@ import {
|
|
|
6
6
|
import type { RelativeTimeInput } from '../time-utils.js'
|
|
7
7
|
import { getRelativeTimeOffsetFromNow } from '../time-utils.js'
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Reads a JWT session from a cookie, and re-issues the cookie after the
|
|
11
|
-
* request whenever the session changed (e.g. after login).
|
|
12
|
-
*/
|
|
13
9
|
/** Standard JWT registered claims — present on a token even with no session. */
|
|
14
10
|
const JWT_REGISTERED_CLAIMS = new Set([
|
|
15
11
|
'iat',
|
|
@@ -31,9 +27,18 @@ const hasSessionIdentity = (session: unknown): boolean => {
|
|
|
31
27
|
return Object.keys(session).some((key) => !JWT_REGISTERED_CLAIMS.has(key))
|
|
32
28
|
}
|
|
33
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Reads a JWT session from a cookie, and re-issues the cookie after the
|
|
32
|
+
* request whenever the session changed (e.g. after login).
|
|
33
|
+
*
|
|
34
|
+
* @example snippet: machineAuth
|
|
35
|
+
*/
|
|
34
36
|
export const authCookie = pikkuMiddlewareFactory<{
|
|
37
|
+
/** Cookie name to read and write. */
|
|
35
38
|
name: string
|
|
39
|
+
/** Serialize options merged over the defaults, which are httpOnly and sameSite lax. */
|
|
36
40
|
options: SerializeOptions
|
|
41
|
+
/** How long the re-issued cookie lives, as a relative time such as `'7d'`. */
|
|
37
42
|
expiresIn: RelativeTimeInput
|
|
38
43
|
}>(({ name, options, expiresIn }) => {
|
|
39
44
|
const mergedOptions: SerializeOptions = {
|
package/src/middleware/cors.ts
CHANGED
|
@@ -8,13 +8,21 @@ import {
|
|
|
8
8
|
* matching origin and otherwise sends no `Access-Control-Allow-Origin` at all,
|
|
9
9
|
* so the browser reports "origin not allowed" rather than an origin mismatch
|
|
10
10
|
* against whichever entry happened to be first.
|
|
11
|
+
*
|
|
12
|
+
* @example snippet: corsMiddleware
|
|
11
13
|
*/
|
|
12
14
|
export const cors = pikkuMiddlewareFactory<{
|
|
15
|
+
/** Which origins may call. Defaults to `*`, which the browser rejects alongside `credentials: true` — name the origins instead. */
|
|
13
16
|
origin?: string | string[] | true
|
|
17
|
+
/** Methods a cross-origin caller may use. Defaults to the common six; a method missing here fails preflight rather than the request. */
|
|
14
18
|
methods?: string[]
|
|
19
|
+
/** Request headers a caller may send. Defaults to content-type, authorization and x-api-key. */
|
|
15
20
|
headers?: string[]
|
|
21
|
+
/** Response headers the browser will let the caller's JavaScript read. Everything else is hidden from it even on a 200. */
|
|
16
22
|
exposeHeaders?: string[]
|
|
23
|
+
/** Whether cookies and auth headers ride along. Requires a named origin, never `*`. */
|
|
17
24
|
credentials?: boolean
|
|
25
|
+
/** Seconds the browser may cache this preflight. Defaults to a day. */
|
|
18
26
|
maxAge?: number
|
|
19
27
|
}>(
|
|
20
28
|
({
|
|
@@ -39,6 +39,10 @@ export const pikkuMiddlewareFactory = <In = any>(
|
|
|
39
39
|
return factory
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Declares middleware for a channel — it runs around the connection and its
|
|
44
|
+
* messages rather than around a single request.
|
|
45
|
+
*/
|
|
42
46
|
export const pikkuChannelMiddleware = <
|
|
43
47
|
SingletonServices extends CoreSingletonServices = CoreSingletonServices,
|
|
44
48
|
Event = unknown,
|
|
@@ -48,12 +52,22 @@ export const pikkuChannelMiddleware = <
|
|
|
48
52
|
return middleware
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Declares channel middleware that takes options, so one definition can be
|
|
57
|
+
* wired several times with different configuration.
|
|
58
|
+
*
|
|
59
|
+
* @example snippet: channelMiddlewareFactory
|
|
60
|
+
*/
|
|
51
61
|
export const pikkuChannelMiddlewareFactory = <In = any>(
|
|
52
62
|
factory: CorePikkuChannelMiddlewareFactory<In>
|
|
53
63
|
): CorePikkuChannelMiddlewareFactory<In> => {
|
|
54
64
|
return factory
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Declares middleware for an agent run — hooks around the model call, its tool
|
|
69
|
+
* calls and the run's state.
|
|
70
|
+
*/
|
|
57
71
|
export const pikkuAgentMiddleware = <
|
|
58
72
|
State extends Record<string, unknown> = Record<string, unknown>,
|
|
59
73
|
SingletonServices extends CoreSingletonServices = CoreSingletonServices,
|
|
@@ -22,7 +22,7 @@ describe('LocalSecretService', () => {
|
|
|
22
22
|
const vars = new LocalVariablesService({})
|
|
23
23
|
const service = new LocalSecretService(vars)
|
|
24
24
|
await assert.rejects(() => service.getSecret('MISSING'), {
|
|
25
|
-
message: 'Requested secret not found',
|
|
25
|
+
message: 'Requested secret not found: MISSING',
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
28
|
|
|
@@ -44,7 +44,7 @@ describe('LocalSecretService', () => {
|
|
|
44
44
|
const vars = new LocalVariablesService({})
|
|
45
45
|
const service = new LocalSecretService(vars)
|
|
46
46
|
await assert.rejects(() => service.getSecret('MISSING'), {
|
|
47
|
-
message: 'Requested secret not found',
|
|
47
|
+
message: 'Requested secret not found: MISSING',
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -32,7 +32,7 @@ export class LocalSecretService implements SecretService {
|
|
|
32
32
|
if (value) {
|
|
33
33
|
return createSecretValue(this.parseSecret<T>(value))
|
|
34
34
|
}
|
|
35
|
-
throw new Error(
|
|
35
|
+
throw new Error(`Requested secret not found: ${key}`)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
public async setSecret(key: string, value: unknown): Promise<void> {
|
|
@@ -8,8 +8,9 @@ import { establishOperatorSession } from './persona-sign-in.js'
|
|
|
8
8
|
const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Minimal deployed stage: the fabric plugin's sign-in,
|
|
12
|
-
*
|
|
11
|
+
* Minimal deployed stage: the fabric plugin's sign-in, which resolves the
|
|
12
|
+
* account to act as in the same request, and an RPC route that reports back who
|
|
13
|
+
* it was addressed as.
|
|
13
14
|
*/
|
|
14
15
|
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
15
16
|
const users = [...seeded]
|
|
@@ -28,38 +29,30 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
28
29
|
res.writeHead(401).end(JSON.stringify({ message: 'bad token' }))
|
|
29
30
|
return
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
let actAs: { userId: string } | undefined
|
|
33
|
+
if (body.actAs) {
|
|
34
|
+
let user = users.find((u) => u.email === body.actAs.email)
|
|
35
|
+
if (!user) {
|
|
36
|
+
if (!body.actAs.create) {
|
|
37
|
+
res
|
|
38
|
+
.writeHead(404)
|
|
39
|
+
.end(
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
message: `No account on this stage for ${body.actAs.email}`,
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
created++
|
|
47
|
+
user = { id: `made-${created}`, email: body.actAs.email }
|
|
48
|
+
users.push(user)
|
|
49
|
+
}
|
|
50
|
+
actAs = { userId: user.id }
|
|
43
51
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (url.pathname === '/api/auth/admin/list-users') {
|
|
47
|
-
const wanted = url.searchParams.get('filterValue')
|
|
48
|
-
res
|
|
49
|
-
.writeHead(200, { 'content-type': 'application/json' })
|
|
50
|
-
.end(
|
|
51
|
-
JSON.stringify({ users: users.filter((u) => u.email === wanted) })
|
|
52
|
-
)
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (url.pathname === '/api/auth/admin/create-user') {
|
|
57
|
-
created++
|
|
58
|
-
const user = { id: `made-${created}`, email: body.email }
|
|
59
|
-
users.push(user)
|
|
52
|
+
res.setHeader('set-cookie', ['session=operator; Path=/; HttpOnly'])
|
|
60
53
|
res
|
|
61
54
|
.writeHead(200, { 'content-type': 'application/json' })
|
|
62
|
-
.end(JSON.stringify({
|
|
55
|
+
.end(JSON.stringify({ token: 'operator', actAs }))
|
|
63
56
|
return
|
|
64
57
|
}
|
|
65
58
|
|
|
@@ -134,7 +127,7 @@ describe('operator persona sign-in', () => {
|
|
|
134
127
|
|
|
135
128
|
await assert.rejects(
|
|
136
129
|
() => personas.customer!.invoke('whoami', {}),
|
|
137
|
-
/
|
|
130
|
+
/operator sign-in failed for 'customer' \(404\)/
|
|
138
131
|
)
|
|
139
132
|
assert.equal(stage.createdCount, 0)
|
|
140
133
|
})
|
|
@@ -178,12 +171,13 @@ describe('operator persona sign-in', () => {
|
|
|
178
171
|
assert.equal(minted, 1)
|
|
179
172
|
})
|
|
180
173
|
|
|
181
|
-
test('
|
|
174
|
+
test('resolves the account to act as without a second request', async () => {
|
|
182
175
|
const stage = await startStage([{ id: 'user-9', email: 'susan@acme.test' }])
|
|
183
176
|
servers.push(stage.server)
|
|
184
177
|
|
|
185
178
|
// Plain fetch keeps no cookies, which is the browser provider's situation:
|
|
186
|
-
// it plants them on a Playwright context only once this has returned.
|
|
179
|
+
// it plants them on a Playwright context only once this has returned. The
|
|
180
|
+
// handshake must not need a session of its own to resolve the persona.
|
|
187
181
|
const { userId } = await establishOperatorSession(
|
|
188
182
|
fetch,
|
|
189
183
|
stage.apiUrl,
|
|
@@ -99,17 +99,10 @@ export interface OperatorSignInOptions {
|
|
|
99
99
|
* for. Turn it on for throwaway stages.
|
|
100
100
|
*/
|
|
101
101
|
createMissing?: boolean
|
|
102
|
-
/** Admin endpoint prefix under apiUrl. Default `/auth/admin`. */
|
|
103
|
-
adminPath?: string
|
|
104
102
|
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
105
103
|
signInPath?: string
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
interface AdminUser {
|
|
109
|
-
id?: unknown
|
|
110
|
-
email?: unknown
|
|
111
|
-
}
|
|
112
|
-
|
|
113
106
|
/** What an operator handshake yields: the session, and who to act as. */
|
|
114
107
|
export interface OperatorSessionResult {
|
|
115
108
|
/** `Set-Cookie` values the operator sign-in returned. */
|
|
@@ -142,7 +135,15 @@ export const establishOperatorSession = async (
|
|
|
142
135
|
const res = await fetchImpl(`${apiUrl}${signInPath}`, {
|
|
143
136
|
method: 'POST',
|
|
144
137
|
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
145
|
-
body: JSON.stringify({
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
token,
|
|
140
|
+
actAs: {
|
|
141
|
+
email: persona.email,
|
|
142
|
+
name: persona.name,
|
|
143
|
+
create: options.createMissing ?? false,
|
|
144
|
+
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
146
147
|
})
|
|
147
148
|
if (!res.ok) {
|
|
148
149
|
throw await failed('operator sign-in', persona.id, res)
|
|
@@ -154,90 +155,17 @@ export const establishOperatorSession = async (
|
|
|
154
155
|
)
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// jar's contents to Playwright only after this returns. Forwarding them
|
|
160
|
-
// explicitly is what keeps the admin calls authenticated for every caller.
|
|
161
|
-
const session = setCookies
|
|
162
|
-
.map((raw) => raw.split(';')[0])
|
|
163
|
-
.filter((pair): pair is string => Boolean(pair))
|
|
164
|
-
.join('; ')
|
|
165
|
-
|
|
166
|
-
const userId = await resolveUserId(fetchImpl, apiUrl, persona, options, {
|
|
167
|
-
...extraHeaders,
|
|
168
|
-
cookie: session,
|
|
169
|
-
})
|
|
170
|
-
return { setCookies, userId }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* The target's own id for this persona's address, since impersonation names a
|
|
175
|
-
* user id and a persona only knows an email.
|
|
176
|
-
*
|
|
177
|
-
* Looked up before creating, so a persona that already exists is never
|
|
178
|
-
* duplicated and the run reads as "act as this person" rather than "make one".
|
|
179
|
-
*/
|
|
180
|
-
const resolveUserId = async (
|
|
181
|
-
fetchImpl: typeof fetch,
|
|
182
|
-
apiUrl: string,
|
|
183
|
-
persona: ResolvedPersona,
|
|
184
|
-
options: OperatorSignInOptions,
|
|
185
|
-
extraHeaders: Record<string, string>
|
|
186
|
-
): Promise<string> => {
|
|
187
|
-
const adminPath = options.adminPath ?? '/auth/admin'
|
|
188
|
-
const query = new URLSearchParams({
|
|
189
|
-
filterField: 'email',
|
|
190
|
-
filterValue: persona.email,
|
|
191
|
-
filterOperator: 'eq',
|
|
192
|
-
limit: '1',
|
|
193
|
-
})
|
|
194
|
-
const found = await fetchImpl(`${apiUrl}${adminPath}/list-users?${query}`, {
|
|
195
|
-
headers: { accept: 'application/json', ...extraHeaders },
|
|
196
|
-
})
|
|
197
|
-
if (!found.ok) {
|
|
198
|
-
throw await failed('persona lookup', persona.id, found)
|
|
199
|
-
}
|
|
200
|
-
const listed = (await found.json().catch(() => null)) as {
|
|
201
|
-
users?: AdminUser[]
|
|
202
|
-
} | null
|
|
203
|
-
const existing = listed?.users?.find((u) => u.email === persona.email)
|
|
204
|
-
if (existing?.id) {
|
|
205
|
-
return String(existing.id)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (!options.createMissing) {
|
|
209
|
-
throw new Error(
|
|
210
|
-
`[scenario] no account on the target for persona '${persona.id}' (${persona.email}) — ` +
|
|
211
|
-
'provision it, or set createMissing on the operator credentials'
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const created = await fetchImpl(`${apiUrl}${adminPath}/create-user`, {
|
|
216
|
-
method: 'POST',
|
|
217
|
-
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
218
|
-
body: JSON.stringify({
|
|
219
|
-
email: persona.email,
|
|
220
|
-
name: persona.name,
|
|
221
|
-
// Never used and never returned: the run impersonates rather than signs
|
|
222
|
-
// in, so the account is reachable only by someone already holding an
|
|
223
|
-
// operator token. A derivable password would undo exactly that.
|
|
224
|
-
password: globalThis.crypto.randomUUID(),
|
|
225
|
-
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
226
|
-
}),
|
|
227
|
-
})
|
|
228
|
-
if (!created.ok) {
|
|
229
|
-
throw await failed('persona creation', persona.id, created)
|
|
230
|
-
}
|
|
231
|
-
const body = (await created.json().catch(() => null)) as {
|
|
232
|
-
user?: AdminUser
|
|
158
|
+
const body = (await res.json().catch(() => null)) as {
|
|
159
|
+
actAs?: { userId?: unknown }
|
|
233
160
|
} | null
|
|
234
|
-
const
|
|
235
|
-
if (!
|
|
161
|
+
const userId = body?.actAs?.userId
|
|
162
|
+
if (!userId) {
|
|
236
163
|
throw new Error(
|
|
237
|
-
`[scenario]
|
|
164
|
+
`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
|
|
165
|
+
'the target is running a @pikku/better-auth too old to resolve one'
|
|
238
166
|
)
|
|
239
167
|
}
|
|
240
|
-
return String(
|
|
168
|
+
return { setCookies, userId: String(userId) }
|
|
241
169
|
}
|
|
242
170
|
|
|
243
171
|
/**
|
|
@@ -38,6 +38,8 @@ export interface ScenarioHttpResponse<T = unknown> {
|
|
|
38
38
|
* `invokeRaw` returns this, and a step that has to reach past a persona — a
|
|
39
39
|
* route with no RPC, an identity no persona can hold — reaches for this rather
|
|
40
40
|
* than writing the same record by hand.
|
|
41
|
+
*
|
|
42
|
+
* @example snippet: scenarioPolling
|
|
41
43
|
*/
|
|
42
44
|
export const readScenarioHttpResponse = async <T = unknown>(
|
|
43
45
|
res: Response
|
|
@@ -84,6 +86,8 @@ export interface ScenarioJsonRequest {
|
|
|
84
86
|
* outright when the target answers an empty body or an HTML error page. A
|
|
85
87
|
* refusal is the expected outcome of a permissions scenario, so it has to
|
|
86
88
|
* survive as data.
|
|
89
|
+
*
|
|
90
|
+
* @example snippet: scenarioHttpStep
|
|
87
91
|
*/
|
|
88
92
|
export const postScenarioJson = async <T = unknown>(
|
|
89
93
|
url: string,
|
|
@@ -4,7 +4,6 @@ import type { SecretValue } from '../classification/secret-value.js'
|
|
|
4
4
|
export type SecretValues<T> = { [K in keyof T]: SecretValue<T[K]> }
|
|
5
5
|
|
|
6
6
|
export interface SecretService {
|
|
7
|
-
/** Throws if the secret is not found. Unwrap the result with `.reveal()`. */
|
|
8
7
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>
|
|
9
8
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
10
9
|
hasSecret(key: string): Promise<boolean>
|
|
@@ -44,7 +44,7 @@ export const defineSecretServiceTests = (
|
|
|
44
44
|
test('getSecret throws for missing key', async () => {
|
|
45
45
|
const service = await factory({ key: kek })
|
|
46
46
|
await assert.rejects(() => service.getSecret('nonexistent'), {
|
|
47
|
-
message: 'Requested secret not found',
|
|
47
|
+
message: 'Requested secret not found: nonexistent',
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
package/src/types/core.types.ts
CHANGED
|
@@ -373,10 +373,15 @@ export type CommonWireMeta = {
|
|
|
373
373
|
pikkuFuncId: string
|
|
374
374
|
packageName?: string
|
|
375
375
|
|
|
376
|
+
/** A human name for this wiring, shown wherever it is listed rather than called. */
|
|
376
377
|
title?: string
|
|
378
|
+
/** Filters this wiring in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
|
|
377
379
|
tags?: string[]
|
|
380
|
+
/** A one-line description for listings, where the full `description` is too long. */
|
|
378
381
|
summary?: string
|
|
382
|
+
/** What this does, for whoever is reading the wiring rather than writing it. */
|
|
379
383
|
description?: string
|
|
384
|
+
/** Names of error classes this may throw, so each one's registered status is used instead of a 500. */
|
|
380
385
|
errors?: string[]
|
|
381
386
|
|
|
382
387
|
middleware?: MiddlewareMetadata[]
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
addTagMiddleware,
|
|
8
8
|
clearMiddlewareCache,
|
|
9
9
|
} from '../../middleware-runner.js'
|
|
10
|
-
import { ForbiddenError } from '../../errors/errors.js'
|
|
10
|
+
import { ForbiddenError, MissingSessionError } from '../../errors/errors.js'
|
|
11
11
|
import { resolveAddonAuth, resolveAddonTags, wireAddon } from './wire-addon.js'
|
|
12
12
|
|
|
13
13
|
const ADDON_PACKAGE = '@addon/console'
|
|
@@ -114,7 +114,7 @@ describe('wireAddon auth on direct wirings', () => {
|
|
|
114
114
|
|
|
115
115
|
await assert.rejects(
|
|
116
116
|
() => callOverDirectWiring('credentialGet'),
|
|
117
|
-
|
|
117
|
+
MissingSessionError
|
|
118
118
|
)
|
|
119
119
|
})
|
|
120
120
|
|
|
@@ -141,7 +141,7 @@ describe('wireAddon auth on direct wirings', () => {
|
|
|
141
141
|
|
|
142
142
|
await assert.rejects(
|
|
143
143
|
() => callOverDirectWiring('credentialGet', { auth: true }),
|
|
144
|
-
|
|
144
|
+
MissingSessionError
|
|
145
145
|
)
|
|
146
146
|
})
|
|
147
147
|
|
|
@@ -161,7 +161,7 @@ describe('wireAddon auth on direct wirings', () => {
|
|
|
161
161
|
]) {
|
|
162
162
|
await assert.rejects(
|
|
163
163
|
() => callOverDirectWiring('credentialGet', { wireType }),
|
|
164
|
-
|
|
164
|
+
MissingSessionError,
|
|
165
165
|
`${wireType} should have been gated`
|
|
166
166
|
)
|
|
167
167
|
}
|
|
@@ -2,35 +2,31 @@ import { pikkuState } from '../../pikku-state.js'
|
|
|
2
2
|
import { getTagGroups } from '../../utils.js'
|
|
3
3
|
import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js'
|
|
4
4
|
export type WireAddonConfig = {
|
|
5
|
+
/** How this instance is addressed. One package may be wired more than once, and the name is what tells the instances apart. */
|
|
5
6
|
name: string
|
|
7
|
+
/** The npm package the addon ships in. */
|
|
6
8
|
package: string
|
|
9
|
+
/** Where to reach the addon when it runs as its own service rather than in-process. */
|
|
7
10
|
rpcEndpoint?: string
|
|
11
|
+
/** Requires a session for every function in the addon, whatever each one declares. Gates an addon whose functions are individually open. */
|
|
8
12
|
auth?: boolean
|
|
13
|
+
/** Offers the addon's functions to MCP clients as tools, without wiring each one. */
|
|
9
14
|
mcp?: boolean
|
|
15
|
+
/** Filters this addon in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
|
|
10
16
|
tags?: string[]
|
|
11
17
|
/** Required of every function in the addon, on top of the function's own. */
|
|
12
18
|
scopes?: string[]
|
|
19
|
+
/** Points a secret the addon reads at a different key in this deployment, so two instances can hold different credentials. */
|
|
13
20
|
secretOverrides?: Record<string, string>
|
|
21
|
+
/** Points a variable the addon reads at a different key in this deployment. */
|
|
14
22
|
variableOverrides?: Record<string, string>
|
|
23
|
+
/** Points a credential the addon reads at a different key in this deployment. */
|
|
15
24
|
credentialOverrides?: Record<string, string>
|
|
16
|
-
/**
|
|
17
|
-
* Secrets this instance may read on top of the ones it declared, named as the
|
|
18
|
-
* addon reads them — the scope check runs before `secretOverrides` renames
|
|
19
|
-
* them, so an overridden secret is named here by its addon-side key. Listing
|
|
20
|
-
* one in `secretOverrides` grants it too.
|
|
21
|
-
*
|
|
22
|
-
* For an addon whose secret names come off its input rather than its own
|
|
23
|
-
* source, this is how the host lends names the addon could not declare.
|
|
24
|
-
*/
|
|
25
|
+
/** Extra secrets this instance may read, named as the addon reads them — the scope check runs before `secretOverrides` renames them. */
|
|
25
26
|
secretGrants?: string[]
|
|
26
27
|
/** Credentials this instance may read on top of the ones it declared. */
|
|
27
28
|
credentialGrants?: string[]
|
|
28
|
-
/**
|
|
29
|
-
* Hands this instance the whole `SecretService` instead of one scoped to the
|
|
30
|
-
* secrets it declared. The value is the reason, recorded in the deploy
|
|
31
|
-
* manifest — an addon that names secrets at runtime cannot be scoped, and
|
|
32
|
-
* only the consuming app, never the addon, can grant it.
|
|
33
|
-
*/
|
|
29
|
+
/** Hands over the whole `SecretService` unscoped. The value is the reason, recorded in the deploy manifest. */
|
|
34
30
|
globalSecrets?: string
|
|
35
31
|
/**
|
|
36
32
|
* Hands this instance the whole `CredentialService` instead of one narrowed
|
|
@@ -40,6 +36,12 @@ export type WireAddonConfig = {
|
|
|
40
36
|
globalCredentials?: string
|
|
41
37
|
}
|
|
42
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Installs an addon into this project: its functions, wirings and scopes become
|
|
41
|
+
* part of the app, under the namespace and options given here.
|
|
42
|
+
*
|
|
43
|
+
* @example snippet: addonWiring
|
|
44
|
+
*/
|
|
43
45
|
export const wireAddon = (config: WireAddonConfig): void => {
|
|
44
46
|
pikkuState(null, 'addons', 'packages').set(config.name, {
|
|
45
47
|
package: config.package,
|
|
@@ -25,6 +25,10 @@ export type WireRemoteAddonConfig = {
|
|
|
25
25
|
tags?: string[]
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Installs an addon that runs as its own deployed service: the contract is
|
|
30
|
+
* local, the calls go over the wire to the addon's own host.
|
|
31
|
+
*/
|
|
28
32
|
export const wireRemoteAddon = (config: WireRemoteAddonConfig): void => {
|
|
29
33
|
pikkuState(null, 'addons', 'packages').set(config.name, {
|
|
30
34
|
package: config.package,
|