@reclaimprotocol/agent 0.1.35 → 0.1.37
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 +8 -8
- package/lib/authoring/backend.d.ts +12 -1
- package/lib/authoring/backend.js +3 -0
- package/lib/authoring/tools.js +33 -5
- package/lib/authoring/util.d.ts +43 -0
- package/lib/authoring/util.js +54 -0
- package/lib/cdp/chrome-for-testing.d.ts +14 -0
- package/lib/cdp/chrome-for-testing.js +43 -0
- package/lib/cdp/geo.d.ts +3 -0
- package/lib/cdp/geo.js +27 -15
- package/lib/cdp/launcher.d.ts +79 -1
- package/lib/cdp/launcher.js +244 -24
- package/lib/logger.js +3 -1
- package/lib/mcp/api/operations.gen.js +1 -1
- package/lib/mcp/instructions.js +3 -3
- package/lib/mcp/start.js +17 -2
- package/lib/mcp/tools/agent/attach.d.ts +6 -0
- package/lib/mcp/tools/agent/attach.js +58 -118
- package/lib/mcp/tools/agent/build.js +6 -0
- package/lib/mcp/tools/agent/capture.js +33 -10
- package/lib/mcp/tools/agent/check-attestor.js +15 -7
- package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
- package/lib/mcp/tools/agent/draft-tools.js +170 -0
- package/lib/mcp/tools/agent/pod-proof.js +5 -1
- package/lib/mcp/tools/agent/proof.d.ts +28 -0
- package/lib/mcp/tools/agent/proof.js +72 -0
- package/lib/mcp/tools/agent/shared.d.ts +8 -1
- package/lib/mcp/tools/agent/shared.js +98 -19
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
- package/lib/mcp/tools/agent/start-integration.js +10 -6
- package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
- package/lib/mcp/tools/authenticate/authenticate.js +212 -116
- package/lib/mcp/tools/authenticate/build.d.ts +2 -2
- package/lib/mcp/tools/authenticate/build.js +6 -3
- package/lib/mcp/tools/authenticate/builder-access.js +5 -2
- package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
- package/lib/mcp/tools/authenticate/eth-key.js +29 -5
- package/lib/mcp/tools/authenticate/index.d.ts +1 -1
- package/lib/mcp/tools/authenticate/index.js +1 -1
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
- package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
- package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
- package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
- package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
- package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
- package/lib/mcp/tools/authenticate/session-cache.js +95 -4
- package/lib/mcp/tools/mode/mode.js +6 -6
- package/lib/mcp/tools/verification/proxy-callback-events.d.ts +21 -3
- package/lib/mcp/tools/verification/proxy-callback-events.js +128 -45
- package/lib/old/login-cdp.js +2 -3
- package/lib/proof/attestor.js +2 -3
- package/lib/proof/check-attestor.d.ts +12 -2
- package/lib/proof/check-attestor.js +40 -10
- package/lib/proof/check-matchers.d.ts +36 -0
- package/lib/proof/check-matchers.js +161 -0
- package/lib/proof/zk-files.d.ts +50 -11
- package/lib/proof/zk-files.js +259 -63
- package/lib/provider/draft.d.ts +24 -1
- package/lib/provider/draft.js +135 -0
- package/lib/provider/matcher.js +171 -4
- package/package.json +6 -4
- package/skill/SKILL.builder.md +7 -2
- package/skill/SKILL.common.md +90 -70
- package/skill/SKILL.old.md +3 -3
- package/skill/builder-mode.md +44 -6
- package/skill/old-mode.md +18 -13
- package/skill/start-integration.md +51 -35
- package/skill/verification-client-integration.md +87 -15
- package/src/authoring/backend.ts +12 -1
- package/src/authoring/tools.ts +48 -7
- package/src/authoring/util.ts +64 -0
- package/src/cdp/chrome-for-testing.ts +66 -0
- package/src/cdp/geo.ts +36 -21
- package/src/cdp/launcher.ts +328 -24
- package/src/logger.ts +3 -1
- package/src/mcp/api/operations.gen.ts +1 -1
- package/src/mcp/instructions.ts +3 -3
- package/src/mcp/start.ts +20 -1
- package/src/mcp/tools/agent/attach.ts +65 -128
- package/src/mcp/tools/agent/build.ts +7 -0
- package/src/mcp/tools/agent/capture.ts +54 -11
- package/src/mcp/tools/agent/check-attestor.ts +15 -7
- package/src/mcp/tools/agent/draft-tools.ts +234 -0
- package/src/mcp/tools/agent/pod-proof.ts +5 -1
- package/src/mcp/tools/agent/proof.ts +110 -1
- package/src/mcp/tools/agent/shared.ts +130 -19
- package/src/mcp/tools/agent/start-integration.ts +10 -6
- package/src/mcp/tools/authenticate/authenticate.ts +276 -131
- package/src/mcp/tools/authenticate/build.ts +6 -3
- package/src/mcp/tools/authenticate/builder-access.ts +5 -2
- package/src/mcp/tools/authenticate/eth-key.ts +41 -7
- package/src/mcp/tools/authenticate/index.ts +1 -1
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
- package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
- package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
- package/src/mcp/tools/authenticate/session-cache.ts +125 -5
- package/src/mcp/tools/mode/mode.ts +6 -6
- package/src/mcp/tools/verification/proxy-callback-events.ts +151 -41
- package/src/old/login-cdp.ts +2 -3
- package/src/proof/attestor.ts +2 -3
- package/src/proof/check-attestor.ts +55 -12
- package/src/proof/check-matchers.ts +227 -0
- package/src/proof/zk-files.ts +357 -65
- package/src/provider/draft.ts +215 -7
- package/src/provider/matcher.ts +237 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { defineTool } from "../../server.js";
|
|
5
|
+
import { normalizeEthHex, readDotenvKey, upsertEnvVar } from "./eth-key.js";
|
|
6
|
+
/**
|
|
7
|
+
* Creates the organization's eth key pair for session binding and encrypted
|
|
8
|
+
* results: writes the private key to the app's server env file and registers
|
|
9
|
+
* only the public key with Builder, so the private key never passes through
|
|
10
|
+
* the agent's transcript. Reuses a key the env file already holds, so a
|
|
11
|
+
* repeat call (for example, to turn on encryption) keeps the same key.
|
|
12
|
+
*/
|
|
13
|
+
export function issueOrgKeypairTool(client) {
|
|
14
|
+
return defineTool({
|
|
15
|
+
name: 'issue_org_keypair',
|
|
16
|
+
description: 'Sets up the organization\'s eth key pair, used for TEE session '
|
|
17
|
+
+ 'binding (`sessions.create({ orgEthPrivateKey })`) and, with '
|
|
18
|
+
+ '`encryptResults`, encrypted result delivery '
|
|
19
|
+
+ '(`results.receive(body, { orgEthPrivateKey })`). Writes the '
|
|
20
|
+
+ 'private key to a server env file in the project (reusing a key '
|
|
21
|
+
+ 'already there) and registers only the public key. Returns the '
|
|
22
|
+
+ 'address, file, and variable; never the key. Returns 409 when the '
|
|
23
|
+
+ 'org already has a different key: ask the developer for that key '
|
|
24
|
+
+ 'rather than replacing it.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['orgId', 'projectDir'],
|
|
28
|
+
properties: {
|
|
29
|
+
orgId: { type: 'string', format: 'uuid' },
|
|
30
|
+
projectDir: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Absolute path of the app\'s project directory.',
|
|
33
|
+
},
|
|
34
|
+
envFile: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Env file to write, relative to `projectDir`. Default `.env`.',
|
|
37
|
+
},
|
|
38
|
+
envVar: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
|
|
41
|
+
description: 'Variable name to set. Default `RECLAIM_ORG_ETH_PRIVATE_KEY`.',
|
|
42
|
+
},
|
|
43
|
+
encryptResults: {
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
description: 'Encrypt delivered results to this key. Default false.',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
50
|
+
}, async (args) => {
|
|
51
|
+
assert(isAbsolute(args.projectDir), new Error('`projectDir` must be an absolute path.'));
|
|
52
|
+
const envFile = args.envFile || '.env';
|
|
53
|
+
const envVar = args.envVar || 'RECLAIM_ORG_ETH_PRIVATE_KEY';
|
|
54
|
+
const envPath = resolve(args.projectDir, envFile);
|
|
55
|
+
const inside = relative(args.projectDir, envPath);
|
|
56
|
+
assert(inside && !inside.startsWith('..') && !isAbsolute(inside), new Error('`envFile` must be inside `projectDir`.'));
|
|
57
|
+
const existing = readDotenvKey(envPath, envVar);
|
|
58
|
+
const keypair = existing
|
|
59
|
+
? deriveFromPrivateKey(normalizeEthHex(existing))
|
|
60
|
+
: generateKeypair();
|
|
61
|
+
// Register first: a 409 (the org has another key) must not leave a
|
|
62
|
+
// key Builder doesn't know in the env file.
|
|
63
|
+
const canEncryptResult = args.encryptResults ?? false;
|
|
64
|
+
await client.call('SetOrgKeypair', {
|
|
65
|
+
params: { orgId: args.orgId },
|
|
66
|
+
body: {
|
|
67
|
+
publicKey: keypair.publicKey,
|
|
68
|
+
canEncryptResult,
|
|
69
|
+
replace: false,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const path = upsertEnvVar(args.projectDir, envFile, envVar, keypair.privateKey);
|
|
73
|
+
return {
|
|
74
|
+
orgId: args.orgId,
|
|
75
|
+
address: keypair.address,
|
|
76
|
+
canEncryptResult,
|
|
77
|
+
envFile: path,
|
|
78
|
+
envVar,
|
|
79
|
+
reused: Boolean(existing),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as oauth from 'openid-client';
|
|
2
|
+
import type { CachedSession } from './session-cache.ts';
|
|
3
|
+
/**
|
|
4
|
+
* `offline_access` makes Better Auth's `oauth-provider` issue a refresh token
|
|
5
|
+
* (it issues one only when the grant carries that scope), so the session can
|
|
6
|
+
* outlive the 1-hour access token.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SCOPE = "openid profile email offline_access";
|
|
9
|
+
/** Where Better Auth mounts its OAuth endpoints under the Builder origin. */
|
|
10
|
+
export declare function authBaseFor(baseUrl: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Request options every openid-client call against Builder's auth server
|
|
13
|
+
* needs. Pass them to `dynamicClientRegistration` or `discovery`; the
|
|
14
|
+
* returned `Configuration` keeps them for its later calls.
|
|
15
|
+
*/
|
|
16
|
+
export declare function oauthRequestOptions(authBase: string): {
|
|
17
|
+
execute?: (typeof oauth.allowInsecureRequests)[] | undefined;
|
|
18
|
+
algorithm: "oauth2";
|
|
19
|
+
[customFetch]: typeof customFetchForOauth;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The session to cache for a token response. A refresh response may omit
|
|
23
|
+
* `refresh_token` (no rotation), so the caller passes the one it already has.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sessionFromTokens(tokens: oauth.TokenEndpointResponse, client: {
|
|
26
|
+
clientId: string;
|
|
27
|
+
authBase: string;
|
|
28
|
+
refreshToken?: string;
|
|
29
|
+
}): CachedSession;
|
|
30
|
+
declare function customFetchForOauth(url: string, options: oauth.CustomFetchOptions): Promise<Response>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as oauth from 'openid-client';
|
|
2
|
+
/**
|
|
3
|
+
* `offline_access` makes Better Auth's `oauth-provider` issue a refresh token
|
|
4
|
+
* (it issues one only when the grant carries that scope), so the session can
|
|
5
|
+
* outlive the 1-hour access token.
|
|
6
|
+
*/
|
|
7
|
+
export const SCOPE = 'openid profile email offline_access';
|
|
8
|
+
/** Where Better Auth mounts its OAuth endpoints under the Builder origin. */
|
|
9
|
+
export function authBaseFor(baseUrl) {
|
|
10
|
+
return `${baseUrl}/auth`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Request options every openid-client call against Builder's auth server
|
|
14
|
+
* needs. Pass them to `dynamicClientRegistration` or `discovery`; the
|
|
15
|
+
* returned `Configuration` keeps them for its later calls.
|
|
16
|
+
*/
|
|
17
|
+
export function oauthRequestOptions(authBase) {
|
|
18
|
+
// openid-client/oauth4webapi refuse any non-HTTPS issuer by default
|
|
19
|
+
// ("only requests to HTTPS are allowed"). Better Auth's own dev default
|
|
20
|
+
// (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
|
|
21
|
+
// `http://localhost:4001` — the server side has no HTTPS requirement at
|
|
22
|
+
// all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
|
|
23
|
+
// always fail here. Loosen it ONLY for loopback hosts, matching what the
|
|
24
|
+
// server already trusts (`authTrustedOrigins()` defaults to
|
|
25
|
+
// localhost:4000/4001 outside production); a non-loopback http:// origin
|
|
26
|
+
// still gets the strict check.
|
|
27
|
+
const isLoopbackAuthBase = ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname);
|
|
28
|
+
return {
|
|
29
|
+
// Better Auth exposes its metadata at the RFC 8414 well-known path
|
|
30
|
+
// (`.well-known/oauth-authorization-server`), not the OIDC one.
|
|
31
|
+
algorithm: 'oauth2',
|
|
32
|
+
// RFC 7591 §3.2.1 requires 201 Created on a successful registration;
|
|
33
|
+
// openid-client enforces that strictly. Better Auth's `oauth-provider`
|
|
34
|
+
// plugin returns 200 with an otherwise well-formed body. `customFetch`
|
|
35
|
+
// persists onto the returned `Configuration` for ALL its future calls
|
|
36
|
+
// (token exchange included, which correctly returns 200), so the URL,
|
|
37
|
+
// not just method+status, gates the rewrite.
|
|
38
|
+
[oauth.customFetch]: customFetchForOauth,
|
|
39
|
+
...(isLoopbackAuthBase ? { execute: [oauth.allowInsecureRequests] } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The session to cache for a token response. A refresh response may omit
|
|
44
|
+
* `refresh_token` (no rotation), so the caller passes the one it already has.
|
|
45
|
+
*/
|
|
46
|
+
export function sessionFromTokens(tokens, client) {
|
|
47
|
+
return {
|
|
48
|
+
token: tokens.access_token,
|
|
49
|
+
expiresAt: new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000).toISOString(),
|
|
50
|
+
refreshToken: tokens.refresh_token ?? client.refreshToken,
|
|
51
|
+
clientId: client.clientId,
|
|
52
|
+
authBase: client.authBase,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function customFetchForOauth(url, options) {
|
|
56
|
+
// openid-client hands over a fetch-compatible options bag whose `body`
|
|
57
|
+
// type is narrower than, but assignable in practice to, `RequestInit`'s.
|
|
58
|
+
const res = await fetch(url, options);
|
|
59
|
+
if (options.method === 'POST' && res.status === 200
|
|
60
|
+
&& new URL(url).pathname.endsWith('/oauth2/register')) {
|
|
61
|
+
return new Response(await res.text(), {
|
|
62
|
+
status: 201,
|
|
63
|
+
headers: res.headers,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return res;
|
|
67
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { type RegisteredTool } from '../../server.ts';
|
|
2
2
|
/**
|
|
3
|
-
* Decide which eth proof-owner key `run_proof`
|
|
4
|
-
*
|
|
5
|
-
* `~/.reclaim` cache) from:
|
|
3
|
+
* Decide which eth proof-owner key `run_proof` signs with. The key is a raw
|
|
4
|
+
* 0x-hex Ethereum private key, resolved (no `~/.reclaim` cache) from:
|
|
6
5
|
* 1. `RECLAIM_PRIVATE_KEY` in the MCP env
|
|
7
6
|
* 2. a `RECLAIM_PRIVATE_KEY=0x…` line in the project `.env`
|
|
8
7
|
* 3. a file at `RECLAIM_PRIVATE_KEY_FILE` holding the raw hex
|
|
9
|
-
* 4. none →
|
|
10
|
-
*
|
|
8
|
+
* 4. none → a local test key is generated into the project `.env`. Nobody
|
|
9
|
+
* is asked; the agent tells the developer afterwards.
|
|
11
10
|
* Never returns the private key — only the derived address.
|
|
12
11
|
*/
|
|
13
12
|
export declare function resolveOwnerKeyTool(): RegisteredTool;
|
|
@@ -1,51 +1,55 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
1
|
import { findProjectRoot } from "../../../paths.js";
|
|
3
2
|
import { defineTool } from "../../server.js";
|
|
4
|
-
import {
|
|
3
|
+
import { GENERATED_KEY_NOTE, resolveOrCreateEthKey } from "./eth-key.js";
|
|
5
4
|
/**
|
|
6
|
-
* Decide which eth proof-owner key `run_proof`
|
|
7
|
-
*
|
|
8
|
-
* `~/.reclaim` cache) from:
|
|
5
|
+
* Decide which eth proof-owner key `run_proof` signs with. The key is a raw
|
|
6
|
+
* 0x-hex Ethereum private key, resolved (no `~/.reclaim` cache) from:
|
|
9
7
|
* 1. `RECLAIM_PRIVATE_KEY` in the MCP env
|
|
10
8
|
* 2. a `RECLAIM_PRIVATE_KEY=0x…` line in the project `.env`
|
|
11
9
|
* 3. a file at `RECLAIM_PRIVATE_KEY_FILE` holding the raw hex
|
|
12
|
-
* 4. none →
|
|
13
|
-
*
|
|
10
|
+
* 4. none → a local test key is generated into the project `.env`. Nobody
|
|
11
|
+
* is asked; the agent tells the developer afterwards.
|
|
14
12
|
* Never returns the private key — only the derived address.
|
|
15
13
|
*/
|
|
16
14
|
export function resolveOwnerKeyTool() {
|
|
17
15
|
return defineTool({
|
|
18
16
|
name: 'resolve_owner_key',
|
|
19
|
-
description: 'Finds the eth proof-owner key `run_proof` signs with
|
|
20
|
-
+ '
|
|
21
|
-
+ '
|
|
22
|
-
+ '
|
|
23
|
-
+ '
|
|
24
|
-
+ '
|
|
25
|
-
+ '
|
|
26
|
-
+ '
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
17
|
+
description: 'Finds the eth proof-owner key `run_proof` signs with, creating a '
|
|
18
|
+
+ 'local test key when none exists. `run_proof` does this itself, '
|
|
19
|
+
+ 'so call it only to show the signing address. Checks '
|
|
20
|
+
+ 'RECLAIM_PRIVATE_KEY in the MCP env, a RECLAIM_PRIVATE_KEY line in '
|
|
21
|
+
+ 'the project .env, then the file at RECLAIM_PRIVATE_KEY_FILE. When '
|
|
22
|
+
+ 'none is set, it writes a new key to the project .env (mode 0600, '
|
|
23
|
+
+ 'added to .gitignore). Returns { ready: true, address, source, '
|
|
24
|
+
+ 'path?, note? }; `source` is `generated` when it created the key, '
|
|
25
|
+
+ 'and `note` then says to tell the developer once, after the fact. '
|
|
26
|
+
+ 'Do not ask the developer for a key. Never returns the key.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
projectDir: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Project directory whose `.env` holds the key. Defaults to '
|
|
33
|
+
+ 'the closest ancestor of cwd containing .git or '
|
|
34
|
+
+ 'package.json.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
annotations: {
|
|
39
|
+
readOnlyHint: false,
|
|
40
|
+
destructiveHint: false,
|
|
41
|
+
idempotentHint: true,
|
|
42
|
+
openWorldHint: false,
|
|
43
|
+
},
|
|
44
|
+
}, async (args) => {
|
|
45
|
+
const projectDir = findProjectRoot(args.projectDir || process.cwd());
|
|
46
|
+
const key = resolveOrCreateEthKey(projectDir);
|
|
40
47
|
return {
|
|
41
|
-
ready:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'import_credentials (a key file). Then call ' +
|
|
47
|
-
'resolve_owner_key again and pass its address to run_proof as ' +
|
|
48
|
-
'ownerAddress.',
|
|
48
|
+
ready: true,
|
|
49
|
+
address: key.address,
|
|
50
|
+
source: key.source,
|
|
51
|
+
...(key.path ? { path: key.path } : {}),
|
|
52
|
+
...(key.source === 'generated' ? { note: GENERATED_KEY_NOTE } : {}),
|
|
49
53
|
};
|
|
50
54
|
});
|
|
51
55
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export type CachedSession = {
|
|
2
2
|
token: string;
|
|
3
|
-
/** ISO timestamp when the token expires
|
|
3
|
+
/** ISO timestamp when the access token expires. */
|
|
4
4
|
expiresAt: string;
|
|
5
5
|
/** Email of the user who authorized (if available). */
|
|
6
6
|
email?: string;
|
|
7
|
+
/** OAuth refresh token; trades for a new access token near expiry. */
|
|
8
|
+
refreshToken?: string;
|
|
9
|
+
/** Public OAuth client the tokens were issued to (refresh needs it). */
|
|
10
|
+
clientId?: string;
|
|
11
|
+
/** Auth server base URL that issued the tokens. */
|
|
12
|
+
authBase?: string;
|
|
7
13
|
};
|
|
8
14
|
/** Persist the auth token to the `session` section of the unified config. */
|
|
9
15
|
export declare function writeSession(session: CachedSession): void;
|
|
@@ -12,3 +18,21 @@ export declare function writeSession(session: CachedSession): void;
|
|
|
12
18
|
* valid (not yet expired). Returns undefined on any error.
|
|
13
19
|
*/
|
|
14
20
|
export declare function readSession(): CachedSession | undefined;
|
|
21
|
+
/** The cached session even when its access token has expired. */
|
|
22
|
+
export declare function readStoredSession(): CachedSession | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Wraps `fetch` for the Builder client so a cached sign-in keeps working past
|
|
25
|
+
* the access token's lifetime. Each request uses the cached session's token,
|
|
26
|
+
* refreshed via the OAuth refresh grant when it's about to expire or when
|
|
27
|
+
* Builder answers 401. If no refresh is possible, the 401 tells the agent to
|
|
28
|
+
* sign in again with `signInTool`. Without a cached session, requests pass
|
|
29
|
+
* through untouched. Don't install it when `RECLAIM_API_TOKEN` is set: that
|
|
30
|
+
* token takes precedence and is never refreshed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sessionRefreshingFetch(signInTool: string, baseFetch?: typeof fetch): typeof fetch;
|
|
33
|
+
/**
|
|
34
|
+
* Trade the session's refresh token for a new access token and cache the
|
|
35
|
+
* result. Returns undefined when the session can't be refreshed; a refresh
|
|
36
|
+
* token the server rejects is dropped so later calls stop retrying it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function refreshSession(session: CachedSession): Promise<CachedSession | undefined>;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { PROBLEM_CONTENT_TYPE } from '@reclaimprotocol/client/api';
|
|
2
|
+
import * as oauth from 'openid-client';
|
|
1
3
|
import { ConfigStore } from "../../../config/store.js";
|
|
4
|
+
import { LOGGER } from "../../../logger.js";
|
|
5
|
+
import { oauthRequestOptions, sessionFromTokens } from "./oauth-config.js";
|
|
6
|
+
/** Refresh this long before the access token actually expires. */
|
|
7
|
+
const EXPIRY_MARGIN_MS = 60 * 1000;
|
|
2
8
|
/** Persist the auth token to the `session` section of the unified config. */
|
|
3
9
|
export function writeSession(session) {
|
|
4
10
|
new ConfigStore().write({ session });
|
|
@@ -8,12 +14,97 @@ export function writeSession(session) {
|
|
|
8
14
|
* valid (not yet expired). Returns undefined on any error.
|
|
9
15
|
*/
|
|
10
16
|
export function readSession() {
|
|
17
|
+
const s = readStoredSession();
|
|
18
|
+
if (!s || new Date(s.expiresAt) <= new Date()) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
/** The cached session even when its access token has expired. */
|
|
24
|
+
export function readStoredSession() {
|
|
11
25
|
const s = new ConfigStore().read()?.session;
|
|
12
|
-
|
|
26
|
+
return s?.token && s.expiresAt ? s : undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wraps `fetch` for the Builder client so a cached sign-in keeps working past
|
|
30
|
+
* the access token's lifetime. Each request uses the cached session's token,
|
|
31
|
+
* refreshed via the OAuth refresh grant when it's about to expire or when
|
|
32
|
+
* Builder answers 401. If no refresh is possible, the 401 tells the agent to
|
|
33
|
+
* sign in again with `signInTool`. Without a cached session, requests pass
|
|
34
|
+
* through untouched. Don't install it when `RECLAIM_API_TOKEN` is set: that
|
|
35
|
+
* token takes precedence and is never refreshed.
|
|
36
|
+
*/
|
|
37
|
+
export function sessionRefreshingFetch(signInTool, baseFetch = fetch) {
|
|
38
|
+
// One refresh at a time: Better Auth rotates refresh tokens and revokes
|
|
39
|
+
// the whole family when a used one is replayed.
|
|
40
|
+
let refreshing;
|
|
41
|
+
return async (input, init) => {
|
|
42
|
+
let session = readStoredSession();
|
|
43
|
+
if (!session) {
|
|
44
|
+
return baseFetch(input, init);
|
|
45
|
+
}
|
|
46
|
+
const expiring = new Date(session.expiresAt).getTime() - Date.now() < EXPIRY_MARGIN_MS;
|
|
47
|
+
const triedRefresh = expiring && !!session.refreshToken;
|
|
48
|
+
if (triedRefresh) {
|
|
49
|
+
session = await refreshOnce(session) ?? session;
|
|
50
|
+
}
|
|
51
|
+
const res = await baseFetch(input, withBearer(init, session.token));
|
|
52
|
+
if (res.status !== 401) {
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
const next = triedRefresh ? undefined : await refreshOnce(session);
|
|
56
|
+
if (next) {
|
|
57
|
+
return baseFetch(input, withBearer(init, next.token));
|
|
58
|
+
}
|
|
59
|
+
return sessionExpiredResponse(signInTool);
|
|
60
|
+
};
|
|
61
|
+
function refreshOnce(session) {
|
|
62
|
+
refreshing ?? (refreshing = refreshSession(session)
|
|
63
|
+
.finally(() => {
|
|
64
|
+
refreshing = undefined;
|
|
65
|
+
}));
|
|
66
|
+
return refreshing;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Trade the session's refresh token for a new access token and cache the
|
|
71
|
+
* result. Returns undefined when the session can't be refreshed; a refresh
|
|
72
|
+
* token the server rejects is dropped so later calls stop retrying it.
|
|
73
|
+
*/
|
|
74
|
+
export async function refreshSession(session) {
|
|
75
|
+
const { refreshToken, clientId, authBase } = session;
|
|
76
|
+
if (!refreshToken || !clientId || !authBase) {
|
|
13
77
|
return undefined;
|
|
14
78
|
}
|
|
15
|
-
|
|
16
|
-
|
|
79
|
+
try {
|
|
80
|
+
const config = await oauth.discovery(new URL(authBase), clientId, undefined, oauth.None(), oauthRequestOptions(authBase));
|
|
81
|
+
const tokens = await oauth.refreshTokenGrant(config, refreshToken);
|
|
82
|
+
const next = {
|
|
83
|
+
...sessionFromTokens(tokens, { clientId, authBase, refreshToken }),
|
|
84
|
+
email: session.email,
|
|
85
|
+
};
|
|
86
|
+
writeSession(next);
|
|
87
|
+
return next;
|
|
17
88
|
}
|
|
18
|
-
|
|
89
|
+
catch (err) {
|
|
90
|
+
LOGGER.warn({ err }, 'builder session refresh failed');
|
|
91
|
+
if (err instanceof oauth.ResponseBodyError) {
|
|
92
|
+
writeSession({ ...session, refreshToken: undefined });
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function withBearer(init, token) {
|
|
98
|
+
const headers = new Headers(init?.headers);
|
|
99
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
100
|
+
return { ...init, headers };
|
|
101
|
+
}
|
|
102
|
+
function sessionExpiredResponse(signInTool) {
|
|
103
|
+
return new Response(JSON.stringify({
|
|
104
|
+
type: 'about:blank',
|
|
105
|
+
title: 'Unauthorized',
|
|
106
|
+
status: 401,
|
|
107
|
+
detail: 'Your Builder sign-in expired and could not be refreshed. '
|
|
108
|
+
+ `Call ${signInTool} to sign in again, then retry.`,
|
|
109
|
+
}), { status: 401, headers: { 'content-type': PROBLEM_CONTENT_TYPE } });
|
|
19
110
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { defaultConfigPath } from "../../../config/store.js";
|
|
2
2
|
import { oldModeSource } from "../../../consts.js";
|
|
3
3
|
import { defineTool } from "../../server.js";
|
|
4
|
-
/** Whether `config`'s cached builder session token is present and
|
|
5
|
-
* expired. Mirrors `readSession`'s check, but against
|
|
6
|
-
* `ConfigStore` instance (not always the default
|
|
7
|
-
* path) so this stays test-isolatable and
|
|
8
|
-
* tool's config reads. */
|
|
4
|
+
/** Whether `config`'s cached builder session token is present and either
|
|
5
|
+
* not yet expired or refreshable. Mirrors `readSession`'s check, but against
|
|
6
|
+
* the CALLER's `ConfigStore` instance (not always the default
|
|
7
|
+
* `~/.reclaim/config.json` path) so this stays test-isolatable and
|
|
8
|
+
* consistent with the rest of this tool's config reads. */
|
|
9
9
|
function hasValidBuilderSession(config) {
|
|
10
10
|
const session = config.read()?.session;
|
|
11
11
|
if (!session?.token || !session.expiresAt) {
|
|
12
12
|
return false;
|
|
13
13
|
}
|
|
14
|
-
return new Date(session.expiresAt) > new Date();
|
|
14
|
+
return !!session.refreshToken || new Date(session.expiresAt) > new Date();
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
|
|
@@ -13,7 +13,20 @@ type Recent = {
|
|
|
13
13
|
httpStatus?: number;
|
|
14
14
|
at: string;
|
|
15
15
|
};
|
|
16
|
+
/** A session whose oldest pending event the app has not accepted. */
|
|
17
|
+
type Blocked = {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
event: string;
|
|
20
|
+
eventId: string;
|
|
21
|
+
httpStatus?: number;
|
|
22
|
+
attempts: number;
|
|
23
|
+
lastError?: string;
|
|
24
|
+
/** When the first attempt failed, as an ISO timestamp. */
|
|
25
|
+
since: string;
|
|
26
|
+
};
|
|
16
27
|
export interface ProxyDeps extends CallbackDeps {
|
|
28
|
+
/** The clock, in milliseconds. Defaults to `Date.now`. */
|
|
29
|
+
now?: () => number;
|
|
17
30
|
/** Delivers an event to the app. Defaults to global `fetch`. */
|
|
18
31
|
forwardFetch?: typeof globalThis.fetch;
|
|
19
32
|
/** Waits between polls. Resolves early, without error, on abort. */
|
|
@@ -23,9 +36,13 @@ export interface ProxyDeps extends CallbackDeps {
|
|
|
23
36
|
/**
|
|
24
37
|
* Polls the org's pending pull events and POSTs each envelope to a local
|
|
25
38
|
* route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
|
|
26
|
-
* anything else (redirects included) stays pending
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* anything else (redirects included) stays pending.
|
|
40
|
+
*
|
|
41
|
+
* Order holds per session. When an event fails, its session is held: its later
|
|
42
|
+
* events are not sent or acknowledged, and the failed event is retried with
|
|
43
|
+
* exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
|
|
44
|
+
* normal poll rate. Held sessions appear in `status().blocked`, and clear when
|
|
45
|
+
* the app accepts the event.
|
|
29
46
|
*/
|
|
30
47
|
declare class Forwarder {
|
|
31
48
|
#private;
|
|
@@ -34,6 +51,7 @@ declare class Forwarder {
|
|
|
34
51
|
constructor(client: ReclaimClient, listener: ListenerInfo, target: Target, deps: ProxyDeps);
|
|
35
52
|
status(): {
|
|
36
53
|
recent: Recent[];
|
|
54
|
+
blocked: Blocked[];
|
|
37
55
|
lastError?: string | undefined;
|
|
38
56
|
forwarding: boolean;
|
|
39
57
|
target: string;
|