@vellumai/credential-executor 0.6.2 → 0.6.4
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/bun.lock +4 -4
- package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
- package/node_modules/@vellumai/ces-contracts/package.json +3 -3
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
- package/package.json +5 -5
- package/src/__tests__/bulk-set-credentials.test.ts +130 -0
- package/src/__tests__/local-token-refresh.test.ts +334 -0
- package/src/http/__tests__/credential-routes-bulk.test.ts +250 -0
- package/src/http/credential-routes.ts +50 -0
- package/src/main.ts +9 -0
- package/src/managed-main.ts +9 -0
- package/src/materializers/local-token-refresh.ts +19 -5
package/bun.lock
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"@vellumai/ces-contracts": "file:../packages/ces-contracts",
|
|
9
9
|
"@vellumai/credential-storage": "file:../packages/credential-storage",
|
|
10
10
|
"@vellumai/egress-proxy": "file:../packages/egress-proxy",
|
|
11
|
-
"pino": "
|
|
12
|
-
"pino-pretty": "
|
|
11
|
+
"pino": "9.14.0",
|
|
12
|
+
"pino-pretty": "13.1.3",
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@types/bun": "
|
|
16
|
-
"typescript": "
|
|
15
|
+
"@types/bun": "1.3.10",
|
|
16
|
+
"typescript": "5.9.3",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -5,22 +5,24 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "@vellumai/ces-contracts",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"zod": "
|
|
8
|
+
"zod": "4.3.6",
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@types/bun": "
|
|
12
|
-
"typescript": "
|
|
11
|
+
"@types/bun": "1.2.4",
|
|
12
|
+
"typescript": "5.7.3",
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
16
|
"packages": {
|
|
17
|
-
"@types/bun": ["@types/bun@1.
|
|
17
|
+
"@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
|
|
18
18
|
|
|
19
19
|
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
|
20
20
|
|
|
21
|
-
"
|
|
21
|
+
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
|
|
22
22
|
|
|
23
|
-
"
|
|
23
|
+
"bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
|
|
24
|
+
|
|
25
|
+
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
|
24
26
|
|
|
25
27
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
|
26
28
|
|
|
@@ -65,6 +65,8 @@ export const CesRpcMethod = {
|
|
|
65
65
|
DeleteCredential: "delete_credential",
|
|
66
66
|
/** List all credential account names. */
|
|
67
67
|
ListCredentials: "list_credentials",
|
|
68
|
+
/** Bulk-import credentials (set multiple at once). */
|
|
69
|
+
BulkSetCredentials: "bulk_set_credentials",
|
|
68
70
|
} as const;
|
|
69
71
|
|
|
70
72
|
export type CesRpcMethod =
|
|
@@ -513,6 +515,38 @@ export type ListCredentialsResponse = z.infer<
|
|
|
513
515
|
typeof ListCredentialsResponseSchema
|
|
514
516
|
>;
|
|
515
517
|
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// bulk_set_credentials
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
export const BulkSetCredentialsSchema = z.object({
|
|
523
|
+
/** Array of credentials to set in bulk. */
|
|
524
|
+
credentials: z.array(
|
|
525
|
+
z.object({
|
|
526
|
+
/** The account name to store the credential under. */
|
|
527
|
+
account: z.string(),
|
|
528
|
+
/** The credential value to store. */
|
|
529
|
+
value: z.string(),
|
|
530
|
+
}),
|
|
531
|
+
),
|
|
532
|
+
});
|
|
533
|
+
export type BulkSetCredentials = z.infer<typeof BulkSetCredentialsSchema>;
|
|
534
|
+
|
|
535
|
+
export const BulkSetCredentialsResponseSchema = z.object({
|
|
536
|
+
/** Per-credential results indicating success or failure. */
|
|
537
|
+
results: z.array(
|
|
538
|
+
z.object({
|
|
539
|
+
/** The account name that was set. */
|
|
540
|
+
account: z.string(),
|
|
541
|
+
/** Whether the credential was successfully stored. */
|
|
542
|
+
ok: z.boolean(),
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
});
|
|
546
|
+
export type BulkSetCredentialsResponse = z.infer<
|
|
547
|
+
typeof BulkSetCredentialsResponseSchema
|
|
548
|
+
>;
|
|
549
|
+
|
|
516
550
|
// ---------------------------------------------------------------------------
|
|
517
551
|
// Full RPC contract type map
|
|
518
552
|
// ---------------------------------------------------------------------------
|
|
@@ -574,6 +608,10 @@ export interface CesRpcContract {
|
|
|
574
608
|
request: ListCredentials;
|
|
575
609
|
response: ListCredentialsResponse;
|
|
576
610
|
};
|
|
611
|
+
[CesRpcMethod.BulkSetCredentials]: {
|
|
612
|
+
request: BulkSetCredentials;
|
|
613
|
+
response: BulkSetCredentialsResponse;
|
|
614
|
+
};
|
|
577
615
|
}
|
|
578
616
|
|
|
579
617
|
/**
|
|
@@ -632,4 +670,8 @@ export const CesRpcSchemas = {
|
|
|
632
670
|
request: ListCredentialsSchema,
|
|
633
671
|
response: ListCredentialsResponseSchema,
|
|
634
672
|
},
|
|
673
|
+
[CesRpcMethod.BulkSetCredentials]: {
|
|
674
|
+
request: BulkSetCredentialsSchema,
|
|
675
|
+
response: BulkSetCredentialsResponseSchema,
|
|
676
|
+
},
|
|
635
677
|
} as const;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/credential-executor",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"@vellumai/ces-contracts": "file:../packages/ces-contracts",
|
|
22
22
|
"@vellumai/credential-storage": "file:../packages/credential-storage",
|
|
23
23
|
"@vellumai/egress-proxy": "file:../packages/egress-proxy",
|
|
24
|
-
"pino": "
|
|
25
|
-
"pino-pretty": "
|
|
24
|
+
"pino": "9.14.0",
|
|
25
|
+
"pino-pretty": "13.1.3"
|
|
26
26
|
},
|
|
27
27
|
"bundledDependencies": [
|
|
28
28
|
"@vellumai/ces-contracts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@vellumai/egress-proxy"
|
|
31
31
|
],
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@types/bun": "
|
|
34
|
-
"typescript": "
|
|
33
|
+
"@types/bun": "1.3.10",
|
|
34
|
+
"typescript": "5.9.3"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the BulkSetCredentials RPC handler.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the handler stores each credential independently and
|
|
5
|
+
* returns per-credential success/failure status.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import { CesRpcMethod } from "@vellumai/ces-contracts";
|
|
11
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a minimal BulkSetCredentials handler using the same logic as
|
|
19
|
+
* main.ts / managed-main.ts, backed by the given SecureKeyBackend.
|
|
20
|
+
*/
|
|
21
|
+
function buildBulkSetHandler(secureKeyBackend: SecureKeyBackend) {
|
|
22
|
+
return async (req: { credentials: Array<{ account: string; value: string }> }) => {
|
|
23
|
+
const results = [];
|
|
24
|
+
for (const { account, value } of req.credentials) {
|
|
25
|
+
const ok = await secureKeyBackend.set(account, value);
|
|
26
|
+
results.push({ account, ok });
|
|
27
|
+
}
|
|
28
|
+
return { results };
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create an in-memory SecureKeyBackend for testing.
|
|
34
|
+
* Optionally accepts a set of accounts that should fail on set().
|
|
35
|
+
*/
|
|
36
|
+
function createMockBackend(failingAccounts: Set<string> = new Set()): SecureKeyBackend & { store: Map<string, string> } {
|
|
37
|
+
const store = new Map<string, string>();
|
|
38
|
+
return {
|
|
39
|
+
store,
|
|
40
|
+
async get(key: string) {
|
|
41
|
+
return store.get(key);
|
|
42
|
+
},
|
|
43
|
+
async set(key: string, value: string) {
|
|
44
|
+
if (failingAccounts.has(key)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
store.set(key, value);
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
async delete(key: string) {
|
|
51
|
+
if (store.has(key)) {
|
|
52
|
+
store.delete(key);
|
|
53
|
+
return "deleted";
|
|
54
|
+
}
|
|
55
|
+
return "not-found";
|
|
56
|
+
},
|
|
57
|
+
async list() {
|
|
58
|
+
return Array.from(store.keys());
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Tests
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("BulkSetCredentials handler", () => {
|
|
68
|
+
test("stores multiple credentials and returns ok: true for each", async () => {
|
|
69
|
+
const backend = createMockBackend();
|
|
70
|
+
const handler = buildBulkSetHandler(backend);
|
|
71
|
+
|
|
72
|
+
const result = await handler({
|
|
73
|
+
credentials: [
|
|
74
|
+
{ account: "acct-1", value: "secret-1" },
|
|
75
|
+
{ account: "acct-2", value: "secret-2" },
|
|
76
|
+
{ account: "acct-3", value: "secret-3" },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.results).toEqual([
|
|
81
|
+
{ account: "acct-1", ok: true },
|
|
82
|
+
{ account: "acct-2", ok: true },
|
|
83
|
+
{ account: "acct-3", ok: true },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Verify all credentials were actually stored
|
|
87
|
+
expect(await backend.get("acct-1")).toBe("secret-1");
|
|
88
|
+
expect(await backend.get("acct-2")).toBe("secret-2");
|
|
89
|
+
expect(await backend.get("acct-3")).toBe("secret-3");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("partial failure returns mixed results without aborting the rest", async () => {
|
|
93
|
+
const failingAccounts = new Set(["acct-2"]);
|
|
94
|
+
const backend = createMockBackend(failingAccounts);
|
|
95
|
+
const handler = buildBulkSetHandler(backend);
|
|
96
|
+
|
|
97
|
+
const result = await handler({
|
|
98
|
+
credentials: [
|
|
99
|
+
{ account: "acct-1", value: "secret-1" },
|
|
100
|
+
{ account: "acct-2", value: "secret-2" },
|
|
101
|
+
{ account: "acct-3", value: "secret-3" },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.results).toEqual([
|
|
106
|
+
{ account: "acct-1", ok: true },
|
|
107
|
+
{ account: "acct-2", ok: false },
|
|
108
|
+
{ account: "acct-3", ok: true },
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
// acct-1 and acct-3 should be stored; acct-2 should not
|
|
112
|
+
expect(await backend.get("acct-1")).toBe("secret-1");
|
|
113
|
+
expect(await backend.get("acct-2")).toBeUndefined();
|
|
114
|
+
expect(await backend.get("acct-3")).toBe("secret-3");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("empty credentials array returns empty results array", async () => {
|
|
118
|
+
const backend = createMockBackend();
|
|
119
|
+
const handler = buildBulkSetHandler(backend);
|
|
120
|
+
|
|
121
|
+
const result = await handler({ credentials: [] });
|
|
122
|
+
|
|
123
|
+
expect(result.results).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("handler is registered under the correct RPC method key", () => {
|
|
127
|
+
// Verify the enum value matches what we register against
|
|
128
|
+
expect(CesRpcMethod.BulkSetCredentials).toBe("bulk_set_credentials");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CES local-token-refresh `refresh_url` support.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that `createLocalTokenRefreshFn`:
|
|
5
|
+
* 1. Uses `refresh_url` when it is set on the provider.
|
|
6
|
+
* 2. Falls back to `token_url` when `refresh_url` is null or empty.
|
|
7
|
+
* 3. Preserves existing `token_exchange_body_format` and `token_endpoint_auth_method` behaviour.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Database from "bun:sqlite";
|
|
11
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
SecureKeyBackend,
|
|
17
|
+
SecureKeyDeleteResult,
|
|
18
|
+
} from "@vellumai/credential-storage";
|
|
19
|
+
|
|
20
|
+
import { createLocalTokenRefreshFn } from "../materializers/local-token-refresh.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Test helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function createMemoryBackend(
|
|
27
|
+
initial: Record<string, string> = {},
|
|
28
|
+
): SecureKeyBackend {
|
|
29
|
+
const store = new Map<string, string>(Object.entries(initial));
|
|
30
|
+
return {
|
|
31
|
+
async get(key: string): Promise<string | undefined> {
|
|
32
|
+
return store.get(key);
|
|
33
|
+
},
|
|
34
|
+
async set(key: string, value: string): Promise<boolean> {
|
|
35
|
+
store.set(key, value);
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
async delete(key: string): Promise<SecureKeyDeleteResult> {
|
|
39
|
+
if (store.has(key)) {
|
|
40
|
+
store.delete(key);
|
|
41
|
+
return "deleted";
|
|
42
|
+
}
|
|
43
|
+
return "not-found";
|
|
44
|
+
},
|
|
45
|
+
async list(): Promise<string[]> {
|
|
46
|
+
return Array.from(store.keys());
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Unique temp root for each test run. */
|
|
52
|
+
let tmpRoot: string;
|
|
53
|
+
|
|
54
|
+
function setupTestDb(opts: {
|
|
55
|
+
providerKey?: string;
|
|
56
|
+
tokenUrl?: string;
|
|
57
|
+
refreshUrl?: string | null;
|
|
58
|
+
tokenEndpointAuthMethod?: string | null;
|
|
59
|
+
tokenExchangeBodyFormat?: string | null;
|
|
60
|
+
}): string {
|
|
61
|
+
const providerKey = opts.providerKey ?? "test-provider";
|
|
62
|
+
const tokenUrl = opts.tokenUrl ?? "https://provider.example.com/token";
|
|
63
|
+
const refreshUrl = opts.refreshUrl ?? null;
|
|
64
|
+
const authMethod = opts.tokenEndpointAuthMethod ?? "client_secret_post";
|
|
65
|
+
const bodyFormat = opts.tokenExchangeBodyFormat ?? "form";
|
|
66
|
+
|
|
67
|
+
tmpRoot = join(
|
|
68
|
+
"/tmp",
|
|
69
|
+
`ces-token-refresh-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
70
|
+
);
|
|
71
|
+
const dbDir = join(tmpRoot, "workspace", "data", "db");
|
|
72
|
+
mkdirSync(dbDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const dbPath = join(dbDir, "assistant.db");
|
|
75
|
+
const db = new Database(dbPath);
|
|
76
|
+
|
|
77
|
+
// Create minimal schema matching the assistant's tables
|
|
78
|
+
db.exec(/*sql*/ `
|
|
79
|
+
CREATE TABLE oauth_providers (
|
|
80
|
+
provider_key TEXT PRIMARY KEY,
|
|
81
|
+
auth_url TEXT NOT NULL,
|
|
82
|
+
token_url TEXT NOT NULL,
|
|
83
|
+
refresh_url TEXT,
|
|
84
|
+
token_endpoint_auth_method TEXT,
|
|
85
|
+
token_exchange_body_format TEXT,
|
|
86
|
+
created_at INTEGER NOT NULL,
|
|
87
|
+
updated_at INTEGER NOT NULL
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
|
|
91
|
+
db.exec(/*sql*/ `
|
|
92
|
+
CREATE TABLE oauth_apps (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
provider_key TEXT NOT NULL REFERENCES oauth_providers(provider_key),
|
|
95
|
+
client_id TEXT NOT NULL,
|
|
96
|
+
client_secret_credential_path TEXT NOT NULL,
|
|
97
|
+
created_at INTEGER NOT NULL,
|
|
98
|
+
updated_at INTEGER NOT NULL
|
|
99
|
+
)
|
|
100
|
+
`);
|
|
101
|
+
|
|
102
|
+
db.exec(/*sql*/ `
|
|
103
|
+
CREATE TABLE oauth_connections (
|
|
104
|
+
id TEXT PRIMARY KEY,
|
|
105
|
+
oauth_app_id TEXT NOT NULL REFERENCES oauth_apps(id),
|
|
106
|
+
provider_key TEXT NOT NULL,
|
|
107
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
108
|
+
created_at INTEGER NOT NULL,
|
|
109
|
+
updated_at INTEGER NOT NULL
|
|
110
|
+
)
|
|
111
|
+
`);
|
|
112
|
+
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
|
|
115
|
+
db.exec(/*sql*/ `
|
|
116
|
+
INSERT INTO oauth_providers (provider_key, auth_url, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format, created_at, updated_at)
|
|
117
|
+
VALUES ('${providerKey}', 'https://provider.example.com/authorize', '${tokenUrl}', ${refreshUrl === null ? "NULL" : `'${refreshUrl}'`}, ${authMethod === null ? "NULL" : `'${authMethod}'`}, ${bodyFormat === null ? "NULL" : `'${bodyFormat}'`}, ${now}, ${now})
|
|
118
|
+
`);
|
|
119
|
+
|
|
120
|
+
db.exec(/*sql*/ `
|
|
121
|
+
INSERT INTO oauth_apps (id, provider_key, client_id, client_secret_credential_path, created_at, updated_at)
|
|
122
|
+
VALUES ('app-1', '${providerKey}', 'test-client-id', 'oauth_app/app-1/client_secret', ${now}, ${now})
|
|
123
|
+
`);
|
|
124
|
+
|
|
125
|
+
db.exec(/*sql*/ `
|
|
126
|
+
INSERT INTO oauth_connections (id, oauth_app_id, provider_key, status, created_at, updated_at)
|
|
127
|
+
VALUES ('conn-1', 'app-1', '${providerKey}', 'active', ${now}, ${now})
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
db.close();
|
|
131
|
+
return tmpRoot;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Mock fetch
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const originalFetch = globalThis.fetch;
|
|
139
|
+
|
|
140
|
+
function mockFetch(capturedUrls: string[]): void {
|
|
141
|
+
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
|
142
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
143
|
+
capturedUrls.push(url);
|
|
144
|
+
return new Response(
|
|
145
|
+
JSON.stringify({
|
|
146
|
+
access_token: "new-access-token",
|
|
147
|
+
refresh_token: "new-refresh-token",
|
|
148
|
+
expires_in: 3600,
|
|
149
|
+
}),
|
|
150
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
151
|
+
);
|
|
152
|
+
}) as unknown as typeof globalThis.fetch;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Tests
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe("createLocalTokenRefreshFn – refresh_url support", () => {
|
|
160
|
+
const capturedUrls: string[] = [];
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
capturedUrls.length = 0;
|
|
164
|
+
mockFetch(capturedUrls);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
globalThis.fetch = originalFetch;
|
|
169
|
+
if (tmpRoot) {
|
|
170
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("uses refresh_url when set on the provider", async () => {
|
|
175
|
+
const root = setupTestDb({
|
|
176
|
+
tokenUrl: "https://provider.example.com/token",
|
|
177
|
+
refreshUrl: "https://provider.example.com/refresh",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const backend = createMemoryBackend({
|
|
181
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
185
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
186
|
+
|
|
187
|
+
expect(result.success).toBe(true);
|
|
188
|
+
expect(capturedUrls).toHaveLength(1);
|
|
189
|
+
expect(capturedUrls[0]).toBe("https://provider.example.com/refresh");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("falls back to token_url when refresh_url is null", async () => {
|
|
193
|
+
const root = setupTestDb({
|
|
194
|
+
tokenUrl: "https://provider.example.com/token",
|
|
195
|
+
refreshUrl: null,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const backend = createMemoryBackend({
|
|
199
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
203
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
204
|
+
|
|
205
|
+
expect(result.success).toBe(true);
|
|
206
|
+
expect(capturedUrls).toHaveLength(1);
|
|
207
|
+
expect(capturedUrls[0]).toBe("https://provider.example.com/token");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("falls back to token_url when refresh_url is an empty string", async () => {
|
|
211
|
+
const root = setupTestDb({
|
|
212
|
+
tokenUrl: "https://provider.example.com/token",
|
|
213
|
+
refreshUrl: "",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const backend = createMemoryBackend({
|
|
217
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
221
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
222
|
+
|
|
223
|
+
expect(result.success).toBe(true);
|
|
224
|
+
expect(capturedUrls).toHaveLength(1);
|
|
225
|
+
expect(capturedUrls[0]).toBe("https://provider.example.com/token");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("preserves token_endpoint_auth_method=client_secret_basic behaviour", async () => {
|
|
229
|
+
const root = setupTestDb({
|
|
230
|
+
refreshUrl: "https://provider.example.com/refresh",
|
|
231
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const backend = createMemoryBackend({
|
|
235
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Capture the fetch call to verify Authorization header
|
|
239
|
+
const capturedHeaders: Record<string, string>[] = [];
|
|
240
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
241
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
242
|
+
capturedUrls.push(url);
|
|
243
|
+
if (init?.headers) {
|
|
244
|
+
capturedHeaders.push(init.headers as Record<string, string>);
|
|
245
|
+
}
|
|
246
|
+
return new Response(
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
access_token: "new-access-token",
|
|
249
|
+
refresh_token: "new-refresh-token",
|
|
250
|
+
expires_in: 3600,
|
|
251
|
+
}),
|
|
252
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
253
|
+
);
|
|
254
|
+
}) as unknown as typeof globalThis.fetch;
|
|
255
|
+
|
|
256
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
257
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(capturedUrls).toHaveLength(1);
|
|
261
|
+
expect(capturedUrls[0]).toBe("https://provider.example.com/refresh");
|
|
262
|
+
|
|
263
|
+
// Verify Basic auth header was sent
|
|
264
|
+
expect(capturedHeaders).toHaveLength(1);
|
|
265
|
+
const expectedCredentials = Buffer.from("test-client-id:test-secret").toString("base64");
|
|
266
|
+
expect(capturedHeaders[0]["Authorization"]).toBe(`Basic ${expectedCredentials}`);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("preserves token_exchange_body_format=json behaviour", async () => {
|
|
270
|
+
const root = setupTestDb({
|
|
271
|
+
refreshUrl: "https://provider.example.com/refresh",
|
|
272
|
+
tokenExchangeBodyFormat: "json",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const backend = createMemoryBackend({
|
|
276
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const capturedContentTypes: string[] = [];
|
|
280
|
+
const capturedBodies: string[] = [];
|
|
281
|
+
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
|
|
282
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
283
|
+
capturedUrls.push(url);
|
|
284
|
+
if (init?.headers) {
|
|
285
|
+
const headers = init.headers as Record<string, string>;
|
|
286
|
+
capturedContentTypes.push(headers["Content-Type"] ?? "");
|
|
287
|
+
}
|
|
288
|
+
if (init?.body) {
|
|
289
|
+
capturedBodies.push(typeof init.body === "string" ? init.body : String(init.body));
|
|
290
|
+
}
|
|
291
|
+
return new Response(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
access_token: "new-access-token",
|
|
294
|
+
refresh_token: "new-refresh-token",
|
|
295
|
+
expires_in: 3600,
|
|
296
|
+
}),
|
|
297
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
298
|
+
);
|
|
299
|
+
}) as unknown as typeof globalThis.fetch;
|
|
300
|
+
|
|
301
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
302
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
303
|
+
|
|
304
|
+
expect(result.success).toBe(true);
|
|
305
|
+
expect(capturedContentTypes).toHaveLength(1);
|
|
306
|
+
expect(capturedContentTypes[0]).toBe("application/json");
|
|
307
|
+
|
|
308
|
+
// Verify the body was sent as JSON
|
|
309
|
+
expect(capturedBodies).toHaveLength(1);
|
|
310
|
+
const parsed = JSON.parse(capturedBodies[0]);
|
|
311
|
+
expect(parsed.grant_type).toBe("refresh_token");
|
|
312
|
+
expect(parsed.refresh_token).toBe("old-refresh-token");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("returns successful token refresh result", async () => {
|
|
316
|
+
const root = setupTestDb({
|
|
317
|
+
refreshUrl: "https://provider.example.com/refresh",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const backend = createMemoryBackend({
|
|
321
|
+
"oauth_app/app-1/client_secret": "test-secret",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const refreshFn = createLocalTokenRefreshFn(root, backend);
|
|
325
|
+
const result = await refreshFn("conn-1", "old-refresh-token");
|
|
326
|
+
|
|
327
|
+
expect(result.success).toBe(true);
|
|
328
|
+
if (result.success) {
|
|
329
|
+
expect(result.accessToken).toBe("new-access-token");
|
|
330
|
+
expect(result.refreshToken).toBe("new-refresh-token");
|
|
331
|
+
expect(result.expiresAt).toBeTypeOf("number");
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the POST /v1/credentials/bulk endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Successful bulk set with multiple credentials
|
|
6
|
+
* - Validation failure (missing fields, non-array body)
|
|
7
|
+
* - Auth requirement (401 without token)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { handleCredentialRoute } from "../credential-routes.js";
|
|
13
|
+
import type { CredentialRouteDeps } from "../credential-routes.js";
|
|
14
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const SERVICE_TOKEN = "test-ces-service-token-12345";
|
|
21
|
+
|
|
22
|
+
function makeDeps(
|
|
23
|
+
overrides: Partial<SecureKeyBackend> = {},
|
|
24
|
+
): CredentialRouteDeps {
|
|
25
|
+
const store = new Map<string, string>();
|
|
26
|
+
const backend: SecureKeyBackend = {
|
|
27
|
+
get: async (account: string) => store.get(account),
|
|
28
|
+
set: async (account: string, value: string) => {
|
|
29
|
+
store.set(account, value);
|
|
30
|
+
return true;
|
|
31
|
+
},
|
|
32
|
+
delete: async (account: string) => {
|
|
33
|
+
if (!store.has(account)) return "not-found";
|
|
34
|
+
store.delete(account);
|
|
35
|
+
return "deleted";
|
|
36
|
+
},
|
|
37
|
+
list: async () => [...store.keys()],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
return { backend, serviceToken: SERVICE_TOKEN };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeRequest(
|
|
44
|
+
opts: {
|
|
45
|
+
body?: unknown;
|
|
46
|
+
token?: string | null;
|
|
47
|
+
method?: string;
|
|
48
|
+
} = {},
|
|
49
|
+
): Request {
|
|
50
|
+
const url = "http://localhost:8090/v1/credentials/bulk";
|
|
51
|
+
const headers: Record<string, string> = {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
};
|
|
54
|
+
if (opts.token !== null) {
|
|
55
|
+
headers["Authorization"] = `Bearer ${opts.token ?? SERVICE_TOKEN}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Request(url, {
|
|
59
|
+
method: opts.method ?? "POST",
|
|
60
|
+
headers,
|
|
61
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Tests
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe("POST /v1/credentials/bulk", () => {
|
|
70
|
+
it("sets multiple credentials and returns per-credential results", async () => {
|
|
71
|
+
const deps = makeDeps();
|
|
72
|
+
const res = await handleCredentialRoute(
|
|
73
|
+
makeRequest({
|
|
74
|
+
body: {
|
|
75
|
+
credentials: [
|
|
76
|
+
{ account: "openai", value: "sk-abc" },
|
|
77
|
+
{ account: "anthropic", value: "sk-xyz" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
deps,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(res).not.toBeNull();
|
|
85
|
+
expect(res!.status).toBe(200);
|
|
86
|
+
|
|
87
|
+
const body = await res!.json();
|
|
88
|
+
expect(body.results).toEqual([
|
|
89
|
+
{ account: "openai", ok: true },
|
|
90
|
+
{ account: "anthropic", ok: true },
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// Verify credentials were actually stored
|
|
94
|
+
expect(await deps.backend.get("openai")).toBe("sk-abc");
|
|
95
|
+
expect(await deps.backend.get("anthropic")).toBe("sk-xyz");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports per-credential failure when backend.set returns false", async () => {
|
|
99
|
+
let callCount = 0;
|
|
100
|
+
const deps = makeDeps({
|
|
101
|
+
set: async () => {
|
|
102
|
+
callCount++;
|
|
103
|
+
// Fail on the second call
|
|
104
|
+
return callCount !== 2;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const res = await handleCredentialRoute(
|
|
109
|
+
makeRequest({
|
|
110
|
+
body: {
|
|
111
|
+
credentials: [
|
|
112
|
+
{ account: "a", value: "1" },
|
|
113
|
+
{ account: "b", value: "2" },
|
|
114
|
+
{ account: "c", value: "3" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
deps,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(res).not.toBeNull();
|
|
122
|
+
expect(res!.status).toBe(200);
|
|
123
|
+
|
|
124
|
+
const body = await res!.json();
|
|
125
|
+
expect(body.results).toEqual([
|
|
126
|
+
{ account: "a", ok: true },
|
|
127
|
+
{ account: "b", ok: false },
|
|
128
|
+
{ account: "c", ok: true },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns 400 when credentials field is not an array", async () => {
|
|
133
|
+
const deps = makeDeps();
|
|
134
|
+
const res = await handleCredentialRoute(
|
|
135
|
+
makeRequest({ body: { credentials: "not-an-array" } }),
|
|
136
|
+
deps,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(res).not.toBeNull();
|
|
140
|
+
expect(res!.status).toBe(400);
|
|
141
|
+
|
|
142
|
+
const body = await res!.json();
|
|
143
|
+
expect(body.error).toMatch(/credentials.*array/i);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns 400 when credentials field is missing", async () => {
|
|
147
|
+
const deps = makeDeps();
|
|
148
|
+
const res = await handleCredentialRoute(
|
|
149
|
+
makeRequest({ body: {} }),
|
|
150
|
+
deps,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(res).not.toBeNull();
|
|
154
|
+
expect(res!.status).toBe(400);
|
|
155
|
+
|
|
156
|
+
const body = await res!.json();
|
|
157
|
+
expect(body.error).toMatch(/credentials.*array/i);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns 400 when an entry is missing account field", async () => {
|
|
161
|
+
const deps = makeDeps();
|
|
162
|
+
const res = await handleCredentialRoute(
|
|
163
|
+
makeRequest({
|
|
164
|
+
body: {
|
|
165
|
+
credentials: [{ value: "sk-abc" }],
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
deps,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(res).not.toBeNull();
|
|
172
|
+
expect(res!.status).toBe(400);
|
|
173
|
+
|
|
174
|
+
const body = await res!.json();
|
|
175
|
+
expect(body.error).toMatch(/account.*value/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns 400 when an entry is missing value field", async () => {
|
|
179
|
+
const deps = makeDeps();
|
|
180
|
+
const res = await handleCredentialRoute(
|
|
181
|
+
makeRequest({
|
|
182
|
+
body: {
|
|
183
|
+
credentials: [{ account: "openai" }],
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
deps,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(res).not.toBeNull();
|
|
190
|
+
expect(res!.status).toBe(400);
|
|
191
|
+
|
|
192
|
+
const body = await res!.json();
|
|
193
|
+
expect(body.error).toMatch(/account.*value/i);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("returns 401 without Authorization header", async () => {
|
|
197
|
+
const deps = makeDeps();
|
|
198
|
+
const res = await handleCredentialRoute(
|
|
199
|
+
makeRequest({ token: null, body: { credentials: [] } }),
|
|
200
|
+
deps,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(res).not.toBeNull();
|
|
204
|
+
expect(res!.status).toBe(401);
|
|
205
|
+
|
|
206
|
+
const body = await res!.json();
|
|
207
|
+
expect(body.error).toMatch(/Missing Authorization/i);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns 403 with wrong service token", async () => {
|
|
211
|
+
const deps = makeDeps();
|
|
212
|
+
const res = await handleCredentialRoute(
|
|
213
|
+
makeRequest({ token: "wrong-token", body: { credentials: [] } }),
|
|
214
|
+
deps,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(res).not.toBeNull();
|
|
218
|
+
expect(res!.status).toBe(403);
|
|
219
|
+
|
|
220
|
+
const body = await res!.json();
|
|
221
|
+
expect(body.error).toMatch(/Invalid service token/i);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("non-POST methods fall through to single-account handler (bulk treated as account name)", async () => {
|
|
225
|
+
const deps = makeDeps();
|
|
226
|
+
const res = await handleCredentialRoute(
|
|
227
|
+
makeRequest({ method: "GET", body: undefined }),
|
|
228
|
+
deps,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(res).not.toBeNull();
|
|
232
|
+
// "bulk" is interpreted as an account name by the :account handler;
|
|
233
|
+
// no such credential exists, so we get 404.
|
|
234
|
+
expect(res!.status).toBe(404);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("handles empty credentials array", async () => {
|
|
238
|
+
const deps = makeDeps();
|
|
239
|
+
const res = await handleCredentialRoute(
|
|
240
|
+
makeRequest({ body: { credentials: [] } }),
|
|
241
|
+
deps,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(res).not.toBeNull();
|
|
245
|
+
expect(res!.status).toBe(200);
|
|
246
|
+
|
|
247
|
+
const body = await res!.json();
|
|
248
|
+
expect(body.results).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Endpoints:
|
|
9
9
|
* - `GET /v1/credentials` — list credential account names
|
|
10
|
+
* - `POST /v1/credentials/bulk` — bulk set credentials
|
|
10
11
|
* - `GET /v1/credentials/:account` — get a credential value
|
|
11
12
|
* - `POST /v1/credentials/:account` — set a credential value
|
|
12
13
|
* - `DELETE /v1/credentials/:account` — delete a credential
|
|
@@ -96,6 +97,55 @@ export async function handleCredentialRoute(
|
|
|
96
97
|
// Extract account from path: /v1/credentials/:account
|
|
97
98
|
const accountSegment = pathname.slice(CREDENTIAL_PATH_PREFIX.length);
|
|
98
99
|
|
|
100
|
+
// POST /v1/credentials/bulk — bulk set credentials
|
|
101
|
+
// Only intercept POST; other methods (GET, DELETE) fall through to the
|
|
102
|
+
// :account handler so a credential literally named "bulk" stays accessible.
|
|
103
|
+
if (accountSegment === "/bulk" && req.method === "POST") {
|
|
104
|
+
let body: { credentials?: unknown };
|
|
105
|
+
try {
|
|
106
|
+
body = await req.json();
|
|
107
|
+
} catch {
|
|
108
|
+
return new Response(
|
|
109
|
+
JSON.stringify({ error: "Invalid JSON body" }),
|
|
110
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!Array.isArray(body.credentials)) {
|
|
115
|
+
return new Response(
|
|
116
|
+
JSON.stringify({ error: "Body must contain a 'credentials' array field" }),
|
|
117
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const entry of body.credentials) {
|
|
122
|
+
if (
|
|
123
|
+
typeof entry !== "object" ||
|
|
124
|
+
entry === null ||
|
|
125
|
+
typeof entry.account !== "string" ||
|
|
126
|
+
typeof entry.value !== "string"
|
|
127
|
+
) {
|
|
128
|
+
return new Response(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
error: "Each credential entry must have string 'account' and 'value' fields",
|
|
131
|
+
}),
|
|
132
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const results: Array<{ account: string; ok: boolean }> = [];
|
|
138
|
+
for (const entry of body.credentials as Array<{ account: string; value: string }>) {
|
|
139
|
+
const ok = await backend.set(entry.account, entry.value);
|
|
140
|
+
results.push({ account: entry.account, ok: !!ok });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({ results }),
|
|
145
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
99
149
|
// GET /v1/credentials — list all credential account names
|
|
100
150
|
if (accountSegment === "" || accountSegment === "/") {
|
|
101
151
|
if (req.method !== "GET") {
|
package/src/main.ts
CHANGED
|
@@ -284,6 +284,15 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
|
|
|
284
284
|
return { accounts };
|
|
285
285
|
}) as typeof handlers[string];
|
|
286
286
|
|
|
287
|
+
handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
|
|
288
|
+
const results = [];
|
|
289
|
+
for (const { account, value } of req.credentials) {
|
|
290
|
+
const ok = await secureKeyBackend.set(account, value);
|
|
291
|
+
results.push({ account, ok });
|
|
292
|
+
}
|
|
293
|
+
return { results };
|
|
294
|
+
}) as typeof handlers[string];
|
|
295
|
+
|
|
287
296
|
return handlers;
|
|
288
297
|
}
|
|
289
298
|
|
package/src/managed-main.ts
CHANGED
|
@@ -344,6 +344,15 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, assista
|
|
|
344
344
|
return { accounts };
|
|
345
345
|
}) as typeof handlers[string];
|
|
346
346
|
|
|
347
|
+
handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
|
|
348
|
+
const results = [];
|
|
349
|
+
for (const { account, value } of req.credentials) {
|
|
350
|
+
const ok = await secureKeyBackend.set(account, value);
|
|
351
|
+
results.push({ account, ok });
|
|
352
|
+
}
|
|
353
|
+
return { results };
|
|
354
|
+
}) as typeof handlers[string];
|
|
355
|
+
|
|
347
356
|
return handlers;
|
|
348
357
|
}
|
|
349
358
|
|
|
@@ -46,7 +46,9 @@ interface OAuthAppRow {
|
|
|
46
46
|
interface OAuthProviderRow {
|
|
47
47
|
provider_key: string;
|
|
48
48
|
token_url: string;
|
|
49
|
+
refresh_url: string | null;
|
|
49
50
|
token_endpoint_auth_method: string | null;
|
|
51
|
+
token_exchange_body_format: string | null;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// ---------------------------------------------------------------------------
|
|
@@ -64,6 +66,7 @@ interface RefreshConfig {
|
|
|
64
66
|
clientId: string;
|
|
65
67
|
clientSecret?: string;
|
|
66
68
|
authMethod: TokenEndpointAuthMethod;
|
|
69
|
+
bodyFormat: "form" | "json";
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -108,7 +111,7 @@ async function resolveRefreshConfig(
|
|
|
108
111
|
// 3. Look up the provider to get token_url and auth method
|
|
109
112
|
const provider = db
|
|
110
113
|
.query<OAuthProviderRow, [string]>(
|
|
111
|
-
`SELECT provider_key, token_url, token_endpoint_auth_method FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
|
|
114
|
+
`SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
|
|
112
115
|
)
|
|
113
116
|
.get(conn.provider_key);
|
|
114
117
|
|
|
@@ -116,7 +119,10 @@ async function resolveRefreshConfig(
|
|
|
116
119
|
return { error: `No OAuth provider found for "${conn.provider_key}"` };
|
|
117
120
|
}
|
|
118
121
|
|
|
119
|
-
|
|
122
|
+
// Resolve the effective token URL: prefer refresh_url, fall back to token_url
|
|
123
|
+
const tokenUrl = provider.refresh_url || provider.token_url;
|
|
124
|
+
|
|
125
|
+
if (!tokenUrl || !app.client_id) {
|
|
120
126
|
return { error: `Missing OAuth2 refresh config for "${conn.provider_key}"` };
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -126,12 +132,14 @@ async function resolveRefreshConfig(
|
|
|
126
132
|
);
|
|
127
133
|
|
|
128
134
|
const authMethod = (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ?? "client_secret_post";
|
|
135
|
+
const bodyFormat = (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
|
|
129
136
|
|
|
130
137
|
return {
|
|
131
|
-
tokenUrl
|
|
138
|
+
tokenUrl,
|
|
132
139
|
clientId: app.client_id,
|
|
133
140
|
clientSecret,
|
|
134
141
|
authMethod,
|
|
142
|
+
bodyFormat,
|
|
135
143
|
};
|
|
136
144
|
} catch (err) {
|
|
137
145
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -161,7 +169,10 @@ async function performTokenRefresh(
|
|
|
161
169
|
};
|
|
162
170
|
|
|
163
171
|
const headers: Record<string, string> = {
|
|
164
|
-
"Content-Type":
|
|
172
|
+
"Content-Type":
|
|
173
|
+
config.bodyFormat === "json"
|
|
174
|
+
? "application/json"
|
|
175
|
+
: "application/x-www-form-urlencoded",
|
|
165
176
|
};
|
|
166
177
|
|
|
167
178
|
if (config.clientSecret && config.authMethod === "client_secret_basic") {
|
|
@@ -179,7 +190,10 @@ async function performTokenRefresh(
|
|
|
179
190
|
const resp = await fetch(config.tokenUrl, {
|
|
180
191
|
method: "POST",
|
|
181
192
|
headers,
|
|
182
|
-
body:
|
|
193
|
+
body:
|
|
194
|
+
config.bodyFormat === "json"
|
|
195
|
+
? JSON.stringify(body)
|
|
196
|
+
: new URLSearchParams(body),
|
|
183
197
|
});
|
|
184
198
|
|
|
185
199
|
if (!resp.ok) {
|