toolcraft 0.0.310 → 0.0.312
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/composition.json +3 -3
- package/dist/composition.json +3 -3
- package/node_modules/auth-store/README.md +20 -0
- package/node_modules/auth-store/dist/encrypted-file-store.d.ts +6 -0
- package/node_modules/auth-store/dist/encrypted-file-store.js +15 -0
- package/node_modules/auth-store/dist/index.d.ts +1 -0
- package/node_modules/auth-store/dist/keychain-store.d.ts +8 -0
- package/node_modules/auth-store/dist/keychain-store.js +13 -0
- package/node_modules/auth-store/dist/transaction-lock.d.ts +24 -0
- package/node_modules/auth-store/dist/transaction-lock.js +152 -0
- package/node_modules/auth-store/dist/types.d.ts +2 -0
- package/node_modules/tiny-http-mcp-server/dist/composition.json +1 -1
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/README.md +30 -0
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +23 -2
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +176 -182
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/session-transaction.d.ts +6 -0
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/session-transaction.js +42 -0
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/token-endpoint.d.ts +3 -1
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/token-endpoint.js +7 -3
- package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/types.d.ts +9 -0
- package/node_modules/tiny-http-mcp-server/package.json +1 -1
- package/node_modules/tiny-mcp-client/dist/index.d.ts +35 -0
- package/node_modules/tiny-mcp-client/dist/index.js +486 -249
- package/node_modules/tiny-stdio-mcp-server/dist/composition.json +1 -1
- package/node_modules/toolcraft-schema/package.json +1 -1
- package/package.json +2 -2
package/composition.json
CHANGED
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
"name": "tiny-http-mcp-server",
|
|
101
|
-
"version": "0.1.
|
|
101
|
+
"version": "0.1.29",
|
|
102
102
|
"license": "MIT"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.312",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.312",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
package/dist/composition.json
CHANGED
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
"name": "tiny-http-mcp-server",
|
|
101
|
-
"version": "0.1.
|
|
101
|
+
"version": "0.1.29",
|
|
102
102
|
"license": "MIT"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.312",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.312",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
|
@@ -25,6 +25,22 @@ const value = await store.get(); // "secret-value"
|
|
|
25
25
|
await store.delete();
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Both built-in backends expose `store.withLock(operation, { signal, timeoutMs })`
|
|
29
|
+
for transactions spanning a read, external operation and write. Independent
|
|
30
|
+
instances and processes serialize the same encrypted file or Keychain
|
|
31
|
+
service/account; unrelated identities proceed independently. The default
|
|
32
|
+
acquisition timeout is 30 seconds. Cancellation during acquisition does not
|
|
33
|
+
release the active owner's lock. Individual `get`, `set` and `delete` calls do
|
|
34
|
+
not implicitly acquire it.
|
|
35
|
+
|
|
36
|
+
Locks use private filesystem claim directories, containing PID/random names
|
|
37
|
+
and numeric tickets without credentials. Dead-owner claims are recovered;
|
|
38
|
+
live claims are never stolen because of age. Empty directories remain so an
|
|
39
|
+
arriving contender cannot race directory removal. Keychain lock storage defaults
|
|
40
|
+
to `~/.auth-store/keychain-locks`; `keychainStore.lock` can select another
|
|
41
|
+
directory or filesystem adapter. Injected encrypted-file adapters need
|
|
42
|
+
`readdir` support when using transactions.
|
|
43
|
+
|
|
28
44
|
## Backends
|
|
29
45
|
|
|
30
46
|
| `backendEnvVar` value | Platform | Backend |
|
|
@@ -40,6 +56,10 @@ await store.delete();
|
|
|
40
56
|
- Configurable salt, directory, and file name
|
|
41
57
|
- File permissions: `0600`
|
|
42
58
|
- Random IV per write
|
|
59
|
+
- `fileStore.throwOnInvalidDocument: true` makes malformed or unauthenticated
|
|
60
|
+
existing documents fail with a safe diagnostic; missing files still return
|
|
61
|
+
`null`. The default treats invalid documents as absent. Strict reads preserve
|
|
62
|
+
the existing file until an explicit reset or replacement.
|
|
43
63
|
|
|
44
64
|
### macOS Keychain
|
|
45
65
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { SecretStore } from "./types.js";
|
|
2
|
+
import { type SecretStoreLockOptions } from "./transaction-lock.js";
|
|
2
3
|
export interface MachineIdentity {
|
|
3
4
|
hostname: string;
|
|
4
5
|
username: string;
|
|
5
6
|
}
|
|
6
7
|
export interface EncryptedFileStoreFileSystem {
|
|
8
|
+
readdir?(path: string): Promise<string[]>;
|
|
7
9
|
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
8
10
|
writeFile(path: string, data: string | NodeJS.ArrayBufferView, options?: {
|
|
9
11
|
encoding?: BufferEncoding;
|
|
@@ -29,6 +31,8 @@ export interface EncryptedFileStoreInput {
|
|
|
29
31
|
getMachineIdentity?: () => MachineIdentity | Promise<MachineIdentity>;
|
|
30
32
|
getHomeDirectory?: () => string;
|
|
31
33
|
getRandomBytes?: (size: number) => Buffer;
|
|
34
|
+
/** Fail closed instead of treating malformed or unauthenticated documents as absent. */
|
|
35
|
+
throwOnInvalidDocument?: boolean;
|
|
32
36
|
}
|
|
33
37
|
export declare class EncryptedFileStore implements SecretStore {
|
|
34
38
|
private readonly fs;
|
|
@@ -38,8 +42,10 @@ export declare class EncryptedFileStore implements SecretStore {
|
|
|
38
42
|
private readonly getMachineIdentity;
|
|
39
43
|
private readonly getRandomBytes;
|
|
40
44
|
private keyPromise;
|
|
45
|
+
private readonly throwOnInvalidDocument;
|
|
41
46
|
constructor(input: EncryptedFileStoreInput);
|
|
42
47
|
get(): Promise<string | null>;
|
|
48
|
+
withLock<T>(operation: () => Promise<T>, options?: SecretStoreLockOptions): Promise<T>;
|
|
43
49
|
set(value: string): Promise<void>;
|
|
44
50
|
delete(): Promise<void>;
|
|
45
51
|
private assertCredentialPathHasNoSymbolicLinks;
|
|
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import { homedir, hostname, userInfo } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { hasOwnErrorCode } from "./error-codes.js";
|
|
6
|
+
import { withSecretStoreFileLock } from "./transaction-lock.js";
|
|
6
7
|
const derivedKeyCache = new Map();
|
|
7
8
|
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
|
8
9
|
const ENCRYPTION_VERSION = 1;
|
|
@@ -18,6 +19,7 @@ export class EncryptedFileStore {
|
|
|
18
19
|
getMachineIdentity;
|
|
19
20
|
getRandomBytes;
|
|
20
21
|
keyPromise = null;
|
|
22
|
+
throwOnInvalidDocument;
|
|
21
23
|
constructor(input) {
|
|
22
24
|
this.fs = input.fs ?? fs;
|
|
23
25
|
this.salt = input.salt;
|
|
@@ -36,6 +38,7 @@ export class EncryptedFileStore {
|
|
|
36
38
|
}
|
|
37
39
|
this.getMachineIdentity = input.getMachineIdentity ?? defaultMachineIdentity;
|
|
38
40
|
this.getRandomBytes = input.getRandomBytes ?? randomBytes;
|
|
41
|
+
this.throwOnInvalidDocument = input.throwOnInvalidDocument ?? false;
|
|
39
42
|
}
|
|
40
43
|
async get() {
|
|
41
44
|
await this.assertCredentialPathHasNoSymbolicLinks(this.filePath);
|
|
@@ -51,6 +54,8 @@ export class EncryptedFileStore {
|
|
|
51
54
|
}
|
|
52
55
|
const document = parseEncryptedDocument(rawDocument);
|
|
53
56
|
if (!document) {
|
|
57
|
+
if (this.throwOnInvalidDocument)
|
|
58
|
+
throw new Error("Invalid encrypted credential document; reset the store explicitly to recover");
|
|
54
59
|
return null;
|
|
55
60
|
}
|
|
56
61
|
const key = await this.getEncryptionKey();
|
|
@@ -60,6 +65,8 @@ export class EncryptedFileStore {
|
|
|
60
65
|
const ciphertext = Buffer.from(document.ciphertext, "base64");
|
|
61
66
|
if (iv.byteLength !== ENCRYPTION_IV_BYTES ||
|
|
62
67
|
authTag.byteLength !== ENCRYPTION_AUTH_TAG_BYTES) {
|
|
68
|
+
if (this.throwOnInvalidDocument)
|
|
69
|
+
throw new Error("Invalid encrypted credential document; reset the store explicitly to recover");
|
|
63
70
|
return null;
|
|
64
71
|
}
|
|
65
72
|
const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
|
@@ -68,9 +75,17 @@ export class EncryptedFileStore {
|
|
|
68
75
|
return plaintext.toString("utf8");
|
|
69
76
|
}
|
|
70
77
|
catch {
|
|
78
|
+
if (this.throwOnInvalidDocument)
|
|
79
|
+
throw new Error("Invalid encrypted credential document; reset the store explicitly to recover");
|
|
71
80
|
return null;
|
|
72
81
|
}
|
|
73
82
|
}
|
|
83
|
+
async withLock(operation, options = {}) {
|
|
84
|
+
await this.assertCredentialPathHasNoSymbolicLinks(`${this.filePath}.lock`);
|
|
85
|
+
if (this.fs.readdir === undefined)
|
|
86
|
+
throw new Error("Secret-store transaction locks require filesystem readdir support");
|
|
87
|
+
return withSecretStoreFileLock(this.fs, `${this.filePath}.lock`, operation, options);
|
|
88
|
+
}
|
|
74
89
|
async set(value) {
|
|
75
90
|
await this.assertCredentialPathHasNoSymbolicLinks(this.filePath);
|
|
76
91
|
const key = await this.getEncryptionKey();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createSecretStore } from "./create-secret-store.js";
|
|
2
2
|
export { EncryptedFileStore } from "./encrypted-file-store.js";
|
|
3
3
|
export { KeychainStore } from "./keychain-store.js";
|
|
4
|
+
export type { SecretStoreLockOptions, SecretStoreLockFileSystem } from "./transaction-lock.js";
|
|
4
5
|
export { key, MigratingSecretStore } from "./provider-store.js";
|
|
5
6
|
export type { SecretStore, StoreBackend, CreateSecretStoreInput, CreateSecretStoreResult } from "./types.js";
|
|
6
7
|
export type { MachineIdentity, EncryptedFileStoreInput, EncryptedFileStoreFileSystem } from "./encrypted-file-store.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SecretStore } from "./types.js";
|
|
2
|
+
import { type SecretStoreLockFileSystem, type SecretStoreLockOptions } from "./transaction-lock.js";
|
|
2
3
|
export interface KeychainCommandResult {
|
|
3
4
|
stdout: string;
|
|
4
5
|
stderr: string;
|
|
@@ -12,13 +13,20 @@ export interface KeychainStoreInput {
|
|
|
12
13
|
runCommand?: KeychainCommandRunner;
|
|
13
14
|
service: string;
|
|
14
15
|
account: string;
|
|
16
|
+
lock?: {
|
|
17
|
+
fs?: SecretStoreLockFileSystem;
|
|
18
|
+
directory?: string;
|
|
19
|
+
};
|
|
15
20
|
}
|
|
16
21
|
export declare class KeychainStore implements SecretStore {
|
|
17
22
|
private readonly runCommand;
|
|
18
23
|
private readonly service;
|
|
19
24
|
private readonly account;
|
|
25
|
+
private readonly lockFs;
|
|
26
|
+
private readonly lockDirectory;
|
|
20
27
|
constructor(input: KeychainStoreInput);
|
|
21
28
|
get(): Promise<string | null>;
|
|
29
|
+
withLock<T>(operation: () => Promise<T>, options?: SecretStoreLockOptions): Promise<T>;
|
|
22
30
|
set(value: string): Promise<void>;
|
|
23
31
|
delete(): Promise<void>;
|
|
24
32
|
private executeSecurityCommand;
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { promises as nodeFs } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { withSecretStoreFileLock } from "./transaction-lock.js";
|
|
2
7
|
const SECURITY_CLI = "security";
|
|
3
8
|
const KEYCHAIN_ITEM_NOT_FOUND_EXIT_CODE = 44;
|
|
4
9
|
export class KeychainStore {
|
|
5
10
|
runCommand;
|
|
6
11
|
service;
|
|
7
12
|
account;
|
|
13
|
+
lockFs;
|
|
14
|
+
lockDirectory;
|
|
8
15
|
constructor(input) {
|
|
9
16
|
this.runCommand = input.runCommand ?? runSecurityCommand;
|
|
10
17
|
this.service = input.service.trim();
|
|
11
18
|
this.account = input.account.trim();
|
|
19
|
+
this.lockFs = input.lock?.fs ?? nodeFs;
|
|
20
|
+
this.lockDirectory = input.lock?.directory ?? path.join(homedir(), ".auth-store", "keychain-locks");
|
|
12
21
|
if (this.service.length === 0) {
|
|
13
22
|
throw new Error("Keychain service must not be empty");
|
|
14
23
|
}
|
|
@@ -26,6 +35,10 @@ export class KeychainStore {
|
|
|
26
35
|
}
|
|
27
36
|
throw createSecurityCliFailure("read secret from macOS Keychain", result);
|
|
28
37
|
}
|
|
38
|
+
async withLock(operation, options = {}) {
|
|
39
|
+
const identity = createHash("sha256").update(JSON.stringify([this.service, this.account])).digest("hex");
|
|
40
|
+
return withSecretStoreFileLock(this.lockFs, path.join(this.lockDirectory, identity), operation, options);
|
|
41
|
+
}
|
|
29
42
|
async set(value) {
|
|
30
43
|
if (value.includes("\n") || value.includes("\r")) {
|
|
31
44
|
throw new Error("Keychain secrets cannot contain line breaks");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SecretStoreLockOptions {
|
|
2
|
+
signal?: AbortSignal;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface SecretStoreLockFileSystem {
|
|
6
|
+
mkdir(path: string, options?: {
|
|
7
|
+
recursive?: boolean;
|
|
8
|
+
mode?: number;
|
|
9
|
+
}): Promise<unknown>;
|
|
10
|
+
readdir(path: string): Promise<string[]>;
|
|
11
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
12
|
+
writeFile(path: string, value: string, options: {
|
|
13
|
+
encoding: BufferEncoding;
|
|
14
|
+
flag: string;
|
|
15
|
+
mode: number;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
rename(from: string, to: string): Promise<void>;
|
|
18
|
+
unlink(path: string): Promise<void>;
|
|
19
|
+
lstat(path: string): Promise<{
|
|
20
|
+
isSymbolicLink(): boolean;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/** Filesystem bakery lock: unique claims allow dead-owner cleanup without deleting a replacement owner's lock. */
|
|
24
|
+
export declare function withSecretStoreFileLock<T>(fs: SecretStoreLockFileSystem, lockDirectory: string, operation: () => Promise<T>, options?: SecretStoreLockOptions): Promise<T>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hasOwnErrorCode } from "./error-codes.js";
|
|
4
|
+
/** Filesystem bakery lock: unique claims allow dead-owner cleanup without deleting a replacement owner's lock. */
|
|
5
|
+
export async function withSecretStoreFileLock(fs, lockDirectory, operation, options = {}) {
|
|
6
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
7
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647)
|
|
8
|
+
throw new Error("Invalid secret-store transaction lock timeout");
|
|
9
|
+
options.signal?.throwIfAborted();
|
|
10
|
+
const deadline = performance.now() + timeoutMs;
|
|
11
|
+
const directory = path.resolve(lockDirectory);
|
|
12
|
+
await assertLockDirectoryPath(fs, directory);
|
|
13
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
await assertLockDirectoryPath(fs, directory);
|
|
15
|
+
const name = `${process.pid}-${randomUUID()}.claim`;
|
|
16
|
+
const claimPath = path.join(directory, name);
|
|
17
|
+
const temporaryPath = `${claimPath}.tmp`;
|
|
18
|
+
let claimed = true;
|
|
19
|
+
let temporaryCreated = false;
|
|
20
|
+
let outcome;
|
|
21
|
+
try {
|
|
22
|
+
try {
|
|
23
|
+
await fs.writeFile(claimPath, JSON.stringify({ ticket: null }), { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (hasOwnErrorCode(error, "EEXIST"))
|
|
27
|
+
claimed = false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const existing = await readClaims(fs, directory, name);
|
|
31
|
+
const ticket = existing.reduce((max, claim) => Math.max(max, claim.ticket ?? 0), 0) + 1;
|
|
32
|
+
if (!Number.isSafeInteger(ticket))
|
|
33
|
+
throw new Error("Secret-store transaction lock ticket overflow");
|
|
34
|
+
temporaryCreated = true;
|
|
35
|
+
try {
|
|
36
|
+
await fs.writeFile(temporaryPath, JSON.stringify({ ticket }), { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (hasOwnErrorCode(error, "EEXIST"))
|
|
40
|
+
temporaryCreated = false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
await fs.rename(temporaryPath, claimPath);
|
|
44
|
+
temporaryCreated = false;
|
|
45
|
+
for (;;) {
|
|
46
|
+
options.signal?.throwIfAborted();
|
|
47
|
+
const peers = await readClaims(fs, directory, name);
|
|
48
|
+
if (!peers.some(peer => peer.ticket === null || peer.ticket < ticket || (peer.ticket === ticket && peer.name < name)))
|
|
49
|
+
break;
|
|
50
|
+
const remaining = deadline - performance.now();
|
|
51
|
+
if (remaining <= 0)
|
|
52
|
+
throw new Error("Timed out waiting for secret-store transaction lock");
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const abort = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); reject(options.signal?.reason); };
|
|
55
|
+
const timer = setTimeout(() => { options.signal?.removeEventListener("abort", abort); resolve(); }, Math.min(10, remaining));
|
|
56
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
57
|
+
if (options.signal?.aborted)
|
|
58
|
+
abort();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
options.signal?.throwIfAborted();
|
|
62
|
+
outcome = { result: await operation() };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
outcome = { error };
|
|
66
|
+
}
|
|
67
|
+
const cleanup = [];
|
|
68
|
+
for (const target of [...(temporaryCreated ? [temporaryPath] : []), ...(claimed ? [claimPath] : [])]) {
|
|
69
|
+
try {
|
|
70
|
+
await fs.unlink(target);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!hasOwnErrorCode(error, "ENOENT"))
|
|
74
|
+
cleanup.push(error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cleanup.length)
|
|
78
|
+
throw new AggregateError([...("error" in outcome ? [outcome.error] : []), ...cleanup], "Secret-store transaction lock cleanup failed");
|
|
79
|
+
if ("error" in outcome)
|
|
80
|
+
throw outcome.error;
|
|
81
|
+
return outcome.result;
|
|
82
|
+
}
|
|
83
|
+
async function assertNoSymbolicLink(fs, target) {
|
|
84
|
+
try {
|
|
85
|
+
if ((await fs.lstat(target)).isSymbolicLink())
|
|
86
|
+
throw new Error("Refusing secret-store transaction lock through symbolic link");
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!hasOwnErrorCode(error, "ENOENT"))
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function assertLockDirectoryPath(fs, directory) {
|
|
94
|
+
const root = path.parse(directory).root;
|
|
95
|
+
const segments = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
96
|
+
let current = root;
|
|
97
|
+
for (const [index, segment] of segments.entries()) {
|
|
98
|
+
current = path.join(current, segment);
|
|
99
|
+
// Match encrypted credential paths: allow OS root aliases such as macOS /var.
|
|
100
|
+
if (index > 0 || segments.length === 1)
|
|
101
|
+
await assertNoSymbolicLink(fs, current);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function readClaims(fs, directory, ownName) {
|
|
105
|
+
const claims = [];
|
|
106
|
+
for (const name of await fs.readdir(directory)) {
|
|
107
|
+
if (name === ownName || !name.endsWith(".claim"))
|
|
108
|
+
continue;
|
|
109
|
+
const pidText = name.slice(0, name.indexOf("-"));
|
|
110
|
+
const pid = Number(pidText);
|
|
111
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || String(pid) !== pidText)
|
|
112
|
+
throw new Error("Malformed secret-store transaction lock owner");
|
|
113
|
+
const target = path.join(directory, name);
|
|
114
|
+
await assertNoSymbolicLink(fs, target);
|
|
115
|
+
let alive = true;
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
alive = !hasOwnErrorCode(error, "ESRCH");
|
|
121
|
+
}
|
|
122
|
+
if (!alive) {
|
|
123
|
+
try {
|
|
124
|
+
await fs.unlink(target);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (!hasOwnErrorCode(error, "ENOENT"))
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let ticket = null;
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = await fs.readFile(target, "utf8");
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (hasOwnErrorCode(error, "ENOENT"))
|
|
139
|
+
continue;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const value = JSON.parse(raw);
|
|
144
|
+
if (value !== null && typeof value === "object" && "ticket" in value &&
|
|
145
|
+
typeof value.ticket === "number" && Number.isSafeInteger(value.ticket) && value.ticket > 0)
|
|
146
|
+
ticket = value.ticket;
|
|
147
|
+
}
|
|
148
|
+
catch { /* Incomplete live claims remain in the choosing phase; never steal them by age. */ }
|
|
149
|
+
claims.push({ name, ticket });
|
|
150
|
+
}
|
|
151
|
+
return claims;
|
|
152
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { EncryptedFileStoreInput } from "./encrypted-file-store.js";
|
|
2
2
|
import type { KeychainStoreInput } from "./keychain-store.js";
|
|
3
|
+
import type { SecretStoreLockOptions } from "./transaction-lock.js";
|
|
3
4
|
export interface SecretStore {
|
|
4
5
|
get(options?: {
|
|
5
6
|
readOnly?: boolean;
|
|
6
7
|
}): Promise<string | null>;
|
|
7
8
|
set(value: string): Promise<void>;
|
|
8
9
|
delete(): Promise<void>;
|
|
10
|
+
withLock?<T>(operation: () => Promise<T>, options?: SecretStoreLockOptions): Promise<T>;
|
|
9
11
|
}
|
|
10
12
|
export type StoreBackend = "file" | "keychain";
|
|
11
13
|
export interface CreateSecretStoreInput {
|
|
@@ -41,6 +41,7 @@ const verifier = createJwksTokenVerifier({
|
|
|
41
41
|
- `mode: "dynamic"` with optional `metadata`
|
|
42
42
|
- `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
|
|
43
43
|
- `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
|
|
44
|
+
- `sessionLockTimeoutMs` limits acquisition waits for a session transaction lock (default 30,000 ms; integer from 1 to 2147483647)
|
|
44
45
|
- `initialGrant: { resource, tokens }` optionally imports an existing Bearer grant for one HTTP resource; requires the original client ID
|
|
45
46
|
- `browser.openBrowser(url)` optional
|
|
46
47
|
- `browser.readLine()` optional
|
|
@@ -106,6 +107,35 @@ Input tokens are copied and invalid expiry values fail before authorization.
|
|
|
106
107
|
|
|
107
108
|
`createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
|
|
108
109
|
|
|
110
|
+
Providers sharing the same `sessionStore` object serialize the complete session
|
|
111
|
+
read, refresh/authorization and persistence transaction for each resource.
|
|
112
|
+
Waiting requests can cancel or time out independently; they cannot release an
|
|
113
|
+
active owner's lock. Custom stores may implement
|
|
114
|
+
`withLock(resource, operation, { signal, timeoutMs })` to serialize the same
|
|
115
|
+
transaction across store instances or processes. The hook must honor acquisition
|
|
116
|
+
cancellation and keep the lock until the operation settles. The timeout bounds
|
|
117
|
+
acquisition, while token and browser operations retain their own deadlines.
|
|
118
|
+
The native `auth-store` session adapter implements this hook for both encrypted
|
|
119
|
+
files and Keychain identities, including across independent processes. Locks
|
|
120
|
+
cover the complete read, refresh/authorization and persisted winner. Dead-owner
|
|
121
|
+
claims are recovered without stealing a live transaction.
|
|
122
|
+
|
|
123
|
+
Before sending a refresh request, the provider persists a tokenless session with
|
|
124
|
+
`refreshState: "pending"`, retaining the original client and discovery binding.
|
|
125
|
+
A successful response replaces it with the rotated grant. A crash, cancellation,
|
|
126
|
+
network disconnect or incomplete response leaves the marker, so another process
|
|
127
|
+
cannot replay a possibly consumed refresh token or revive the initial import.
|
|
128
|
+
Such a session requires fresh authorization. Interactive unauthorized handling
|
|
129
|
+
can recover it; headless requests fail with an explicit unknown-outcome error.
|
|
130
|
+
Only complete OAuth error responses establish a rejected request and allow a
|
|
131
|
+
transient retry or restoration of the original grant. Gateway error pages do not.
|
|
132
|
+
|
|
133
|
+
Native persisted OAuth reads always reject corrupt encrypted documents and
|
|
134
|
+
invalid stored JSON, with diagnostics that omit decrypted contents. They retain
|
|
135
|
+
the existing record for explicit reset rather than interpreting corruption as
|
|
136
|
+
an absent session and reviving an initial grant. Caller file-backend settings
|
|
137
|
+
cannot disable this policy.
|
|
138
|
+
|
|
109
139
|
## Environment Variables
|
|
110
140
|
|
|
111
141
|
This package exposes no direct environment variables. When `authStore` is used,
|
|
@@ -11,13 +11,25 @@ const DEFAULT_CLIENT_KEYCHAIN_SERVICE = "poe-code-mcp-oauth-clients";
|
|
|
11
11
|
const MAX_JS_DATE_MS = 8_640_000_000_000_000;
|
|
12
12
|
export function createAuthStoreSessionStore(options = {}) {
|
|
13
13
|
return {
|
|
14
|
+
async withLock(resource, operation, lockOptions) {
|
|
15
|
+
const store = createResourceSecretStore(resource, options);
|
|
16
|
+
if (store.withLock === undefined)
|
|
17
|
+
throw new Error("OAuth secret-store backend does not support transaction locks");
|
|
18
|
+
return store.withLock(operation, lockOptions);
|
|
19
|
+
},
|
|
14
20
|
async load(resource) {
|
|
15
21
|
const store = createResourceSecretStore(resource, options);
|
|
16
22
|
const value = await store.get();
|
|
17
23
|
if (value === null) {
|
|
18
24
|
return null;
|
|
19
25
|
}
|
|
20
|
-
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error("Stored OAuth session must be valid JSON; reset the store explicitly to recover");
|
|
32
|
+
}
|
|
21
33
|
if (isStoredOAuthSession(parsed)) {
|
|
22
34
|
return parsed;
|
|
23
35
|
}
|
|
@@ -41,7 +53,13 @@ export function createAuthStoreClientStore(options) {
|
|
|
41
53
|
if (value === null) {
|
|
42
54
|
return null;
|
|
43
55
|
}
|
|
44
|
-
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error("Stored OAuth client must be valid JSON; reset the store explicitly to recover");
|
|
62
|
+
}
|
|
45
63
|
const clientId = isObjectRecord(parsed) ? getOwnString(parsed, "clientId") : undefined;
|
|
46
64
|
if (clientId !== undefined) {
|
|
47
65
|
const client = { clientId };
|
|
@@ -69,6 +87,7 @@ function createNamedSecretStore(key, options, defaults) {
|
|
|
69
87
|
const parsedFilePath = configuredFilePath === undefined ? null : path.parse(configuredFilePath);
|
|
70
88
|
const fileStore = {
|
|
71
89
|
...options.fileStore,
|
|
90
|
+
throwOnInvalidDocument: true,
|
|
72
91
|
filePath: parsedFilePath === null
|
|
73
92
|
? undefined
|
|
74
93
|
: path.join(parsedFilePath.dir, `${parsedFilePath.name}-${hash}${parsedFilePath.ext || ".enc"}`),
|
|
@@ -119,6 +138,8 @@ function isStoredOAuthSession(value) {
|
|
|
119
138
|
isNonBlankOwnString(value, "authorizationServer") &&
|
|
120
139
|
isStoredOAuthClient(getOwnEntry(value, "client")) &&
|
|
121
140
|
isStoredOAuthDiscovery(getOwnEntry(value, "discovery")) &&
|
|
141
|
+
(getOwnEntry(value, "refreshState") === undefined ||
|
|
142
|
+
(getOwnEntry(value, "refreshState") === "pending" && getOwnEntry(value, "tokens") === undefined)) &&
|
|
122
143
|
isStoredOAuthTokensOrMissing(getOwnEntry(value, "tokens")));
|
|
123
144
|
}
|
|
124
145
|
function isStoredOAuthClient(value) {
|