catence 0.2.1-beta.17 → 0.2.1-beta.19
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/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +341 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/interfaces/cli/commands.js +49 -5
- package/dist/interfaces/http/cli-options.d.ts +35 -2
- package/dist/interfaces/http/cli-options.js +119 -3
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +224 -0
- package/dist/interfaces/http/server.d.ts +45 -1
- package/dist/interfaces/http/server.js +444 -34
- package/dist/interfaces/mcp/main.js +21 -1
- package/dist/interfaces/mcp/server.d.ts +12 -1
- package/dist/interfaces/mcp/server.js +21 -5
- package/dist/runtime/index.d.ts +8 -0
- package/dist/runtime/index.js +5 -0
- package/docs/deployment/docker.md +48 -10
- package/docs/deployment/local-mcp.md +145 -12
- package/package.json +7 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.19";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.19';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CatalogPaths } from './catalog.js';
|
|
3
|
+
declare const accountSchema: z.ZodObject<{
|
|
4
|
+
username: z.ZodString;
|
|
5
|
+
passwordHash: z.ZodString;
|
|
6
|
+
role: z.ZodEnum<{
|
|
7
|
+
admin: "admin";
|
|
8
|
+
member: "member";
|
|
9
|
+
}>;
|
|
10
|
+
athletes: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>;
|
|
11
|
+
createdAt: z.ZodString;
|
|
12
|
+
}, z.core.$strict>;
|
|
13
|
+
export type ConsoleAccount = z.infer<typeof accountSchema>;
|
|
14
|
+
export type ConsoleAccountRole = ConsoleAccount['role'];
|
|
15
|
+
export type ConsoleAccountScope = {
|
|
16
|
+
role: ConsoleAccountRole;
|
|
17
|
+
athleteIds: string[] | 'all';
|
|
18
|
+
};
|
|
19
|
+
export declare function consoleAccountsPath(paths: CatalogPaths): string;
|
|
20
|
+
/**
|
|
21
|
+
* Read and strictly validate the Console accounts store. A missing file is an
|
|
22
|
+
* empty store; any other read or validation problem throws so callers fail
|
|
23
|
+
* closed instead of silently dropping accounts.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadConsoleAccounts(paths: CatalogPaths): ConsoleAccount[];
|
|
26
|
+
/**
|
|
27
|
+
* The athlete scope one account may act for, or null when the username is not
|
|
28
|
+
* stored. An admin holding a grant list instead of "all" keeps the list (fail
|
|
29
|
+
* safe); any account holding "all" sees every athlete.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveAccountScope(paths: CatalogPaths, username: string): ConsoleAccountScope | null;
|
|
32
|
+
/** True when the scope grants this athlete: "all", or the id is listed. */
|
|
33
|
+
export declare function scopeAllowsAthlete(athleteIds: string[] | 'all', athleteId: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Verify a Console account password against its stored bcrypt hash (the
|
|
36
|
+
* OAuth consent login). Fail closed: a missing account, a malformed store, or
|
|
37
|
+
* a malformed hash all return false. bcryptjs is imported lazily so the stdio
|
|
38
|
+
* path never loads it. The password is verified, never logged or stored.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyAccountPassword(paths: CatalogPaths, username: string, password: string): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Narrow an athlete roster to a scope, mirroring the Console's filter_roster:
|
|
43
|
+
* "all" returns the roster unchanged; otherwise only granted athletes remain
|
|
44
|
+
* and the default moves to the first visible athlete (or "" when none).
|
|
45
|
+
*/
|
|
46
|
+
export declare function filterRosterToScope<Athlete extends {
|
|
47
|
+
id: string;
|
|
48
|
+
}>(athletes: Athlete[], defaultAthleteId: string, athleteIds: string[] | 'all'): {
|
|
49
|
+
athletes: Athlete[];
|
|
50
|
+
defaultAthleteId: string;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const usernameSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, 'must be 1-64 characters of letters, digits, dots, underscores, or hyphens');
|
|
5
|
+
const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/, 'must use lowercase letters, digits, and hyphens, starting with a letter');
|
|
6
|
+
const accountSchema = z.object({
|
|
7
|
+
username: usernameSchema,
|
|
8
|
+
passwordHash: z.string().startsWith('$2', "must be a bcrypt hash starting with '$2'"),
|
|
9
|
+
role: z.enum(['admin', 'member']),
|
|
10
|
+
athletes: z.union([z.literal('all'), z.array(athleteIdSchema)]),
|
|
11
|
+
createdAt: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), 'must be an ISO timestamp'),
|
|
12
|
+
}).strict();
|
|
13
|
+
const accountsSchema = z.object({
|
|
14
|
+
formatVersion: z.literal(1),
|
|
15
|
+
accounts: z.array(accountSchema),
|
|
16
|
+
}).strict().superRefine((store, context) => {
|
|
17
|
+
const usernames = store.accounts.map((account) => account.username);
|
|
18
|
+
if (new Set(usernames).size !== usernames.length)
|
|
19
|
+
context.addIssue({ code: 'custom', path: ['accounts'], message: 'account usernames must be unique' });
|
|
20
|
+
});
|
|
21
|
+
export function consoleAccountsPath(paths) {
|
|
22
|
+
return path.join(paths.console, 'accounts.json');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read and strictly validate the Console accounts store. A missing file is an
|
|
26
|
+
* empty store; any other read or validation problem throws so callers fail
|
|
27
|
+
* closed instead of silently dropping accounts.
|
|
28
|
+
*/
|
|
29
|
+
export function loadConsoleAccounts(paths) {
|
|
30
|
+
const filePath = consoleAccountsPath(paths);
|
|
31
|
+
let contents;
|
|
32
|
+
try {
|
|
33
|
+
contents = readFileSync(filePath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT')
|
|
37
|
+
return [];
|
|
38
|
+
throw new Error(`Cannot read Console accounts at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return accountsSchema.parse(JSON.parse(contents)).accounts;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new Error(`Invalid Console accounts at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The athlete scope one account may act for, or null when the username is not
|
|
49
|
+
* stored. An admin holding a grant list instead of "all" keeps the list (fail
|
|
50
|
+
* safe); any account holding "all" sees every athlete.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveAccountScope(paths, username) {
|
|
53
|
+
const account = loadConsoleAccounts(paths).find((entry) => entry.username === username);
|
|
54
|
+
if (!account)
|
|
55
|
+
return null;
|
|
56
|
+
return { role: account.role, athleteIds: account.athletes === 'all' ? 'all' : account.athletes };
|
|
57
|
+
}
|
|
58
|
+
/** True when the scope grants this athlete: "all", or the id is listed. */
|
|
59
|
+
export function scopeAllowsAthlete(athleteIds, athleteId) {
|
|
60
|
+
return athleteIds === 'all' || athleteIds.includes(athleteId);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Verify a Console account password against its stored bcrypt hash (the
|
|
64
|
+
* OAuth consent login). Fail closed: a missing account, a malformed store, or
|
|
65
|
+
* a malformed hash all return false. bcryptjs is imported lazily so the stdio
|
|
66
|
+
* path never loads it. The password is verified, never logged or stored.
|
|
67
|
+
*/
|
|
68
|
+
export async function verifyAccountPassword(paths, username, password) {
|
|
69
|
+
if (!username || !password)
|
|
70
|
+
return false;
|
|
71
|
+
let account;
|
|
72
|
+
try {
|
|
73
|
+
account = loadConsoleAccounts(paths).find((entry) => entry.username === username);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (!account)
|
|
79
|
+
return false;
|
|
80
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
81
|
+
try {
|
|
82
|
+
return await bcrypt.compare(password, account.passwordHash);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Narrow an athlete roster to a scope, mirroring the Console's filter_roster:
|
|
90
|
+
* "all" returns the roster unchanged; otherwise only granted athletes remain
|
|
91
|
+
* and the default moves to the first visible athlete (or "" when none).
|
|
92
|
+
*/
|
|
93
|
+
export function filterRosterToScope(athletes, defaultAthleteId, athleteIds) {
|
|
94
|
+
if (athleteIds === 'all')
|
|
95
|
+
return { athletes, defaultAthleteId };
|
|
96
|
+
const visible = athletes.filter((athlete) => athleteIds.includes(athlete.id));
|
|
97
|
+
const visibleIds = visible.map((athlete) => athlete.id);
|
|
98
|
+
return {
|
|
99
|
+
athletes: visible,
|
|
100
|
+
defaultAthleteId: visibleIds.includes(defaultAthleteId) ? defaultAthleteId : (visibleIds[0] ?? ''),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=console-accounts.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ConsoleAccountScope } from './console-accounts.js';
|
|
3
|
+
declare const keySchema: z.ZodObject<{
|
|
4
|
+
id: z.ZodString;
|
|
5
|
+
label: z.ZodString;
|
|
6
|
+
account: z.ZodString;
|
|
7
|
+
tokenHash: z.ZodString;
|
|
8
|
+
createdAt: z.ZodString;
|
|
9
|
+
revokedAt: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strict>;
|
|
11
|
+
declare const registrySchema: z.ZodObject<{
|
|
12
|
+
formatVersion: z.ZodLiteral<1>;
|
|
13
|
+
keys: z.ZodArray<z.ZodObject<{
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
label: z.ZodString;
|
|
16
|
+
account: z.ZodString;
|
|
17
|
+
tokenHash: z.ZodString;
|
|
18
|
+
createdAt: z.ZodString;
|
|
19
|
+
revokedAt: z.ZodOptional<z.ZodString>;
|
|
20
|
+
}, z.core.$strict>>;
|
|
21
|
+
}, z.core.$strict>;
|
|
22
|
+
/** The stored key record; tokenHash is the SHA-256 hex digest of the raw token. */
|
|
23
|
+
export type McpKeyRecord = z.infer<typeof keySchema>;
|
|
24
|
+
/** The public key view: the stored record without the token hash. */
|
|
25
|
+
export type McpKeySummary = Omit<McpKeyRecord, 'tokenHash'>;
|
|
26
|
+
export type McpKeyRegistry = z.infer<typeof registrySchema>;
|
|
27
|
+
export type McpKeyPrincipal = ConsoleAccountScope & {
|
|
28
|
+
account: string;
|
|
29
|
+
};
|
|
30
|
+
export declare class McpKeyNotFoundError extends Error {
|
|
31
|
+
}
|
|
32
|
+
type AccountScopeResolver = (username: string) => ConsoleAccountScope | null;
|
|
33
|
+
/** The number of non-revoked keys; a missing or malformed registry counts zero. */
|
|
34
|
+
export declare function countActiveMcpKeys(keysFile: string): number;
|
|
35
|
+
/**
|
|
36
|
+
* Create a per-account key. The raw token is generated here, stored only as a
|
|
37
|
+
* SHA-256 hex digest, and returned exactly once.
|
|
38
|
+
*/
|
|
39
|
+
export declare function addMcpKey(keysFile: string, input: {
|
|
40
|
+
account: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
record: McpKeyRecord;
|
|
44
|
+
token: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** All keys without their token hashes; a missing registry lists as empty. */
|
|
47
|
+
export declare function listMcpKeys(keysFile: string): Promise<McpKeySummary[]>;
|
|
48
|
+
/** Revoke a key by id. Revoking an already-revoked key is a no-op. */
|
|
49
|
+
export declare function revokeMcpKey(keysFile: string, id: string): Promise<McpKeyRecord>;
|
|
50
|
+
/**
|
|
51
|
+
* Replace a key's token: same id, account, and label, revokedAt cleared, and a
|
|
52
|
+
* new raw token returned exactly once. The previous token stops resolving.
|
|
53
|
+
*/
|
|
54
|
+
export declare function rollMcpKey(keysFile: string, id: string): Promise<{
|
|
55
|
+
record: McpKeyRecord;
|
|
56
|
+
token: string;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a raw bearer token to the key's account principal, or null when the
|
|
60
|
+
* token matches nothing. Comparison is timing-safe over the fixed-length
|
|
61
|
+
* SHA-256 hex digests. Fail closed: an unreadable or malformed registry, a
|
|
62
|
+
* revoked key, or a key whose account no longer exists all resolve to null.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveKeyPrincipal(keysFile: string, rawToken: string, resolveAccount: AccountScopeResolver): McpKeyPrincipal | null;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const MCP_KEYS_FORMAT_VERSION = 1;
|
|
7
|
+
const accountSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, 'must be 1-64 characters of letters, digits, dots, underscores, or hyphens');
|
|
8
|
+
const keySchema = z.object({
|
|
9
|
+
id: z.string().regex(/^[0-9a-f]{32}$/, 'must be a 32-character hex id'),
|
|
10
|
+
label: z.string().trim().min(1).max(120),
|
|
11
|
+
account: accountSchema,
|
|
12
|
+
tokenHash: z.string().regex(/^[0-9a-f]{64}$/, 'must be a SHA-256 hex digest'),
|
|
13
|
+
createdAt: z.string().datetime(),
|
|
14
|
+
revokedAt: z.string().datetime().optional(),
|
|
15
|
+
}).strict();
|
|
16
|
+
const registrySchema = z.object({
|
|
17
|
+
formatVersion: z.literal(MCP_KEYS_FORMAT_VERSION),
|
|
18
|
+
keys: z.array(keySchema),
|
|
19
|
+
}).strict();
|
|
20
|
+
export class McpKeyNotFoundError extends Error {
|
|
21
|
+
}
|
|
22
|
+
function hashToken(rawToken) {
|
|
23
|
+
return createHash('sha256').update(rawToken, 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function newToken() {
|
|
26
|
+
return randomBytes(32).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
function newKeyId() {
|
|
29
|
+
return randomBytes(16).toString('hex');
|
|
30
|
+
}
|
|
31
|
+
function emptyRegistry() {
|
|
32
|
+
return { formatVersion: MCP_KEYS_FORMAT_VERSION, keys: [] };
|
|
33
|
+
}
|
|
34
|
+
/** Strict registry read for mutations and listings: malformed fails loudly. */
|
|
35
|
+
async function loadRegistry(keysFile) {
|
|
36
|
+
const contents = await readFile(keysFile, 'utf8').catch((error) => {
|
|
37
|
+
if (error.code !== 'ENOENT')
|
|
38
|
+
throw error;
|
|
39
|
+
return null;
|
|
40
|
+
});
|
|
41
|
+
if (contents === null)
|
|
42
|
+
return emptyRegistry();
|
|
43
|
+
try {
|
|
44
|
+
return registrySchema.parse(JSON.parse(contents));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new Error(`Invalid MCP key registry at ${keysFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function writeRegistry(keysFile, registry) {
|
|
51
|
+
await mkdir(path.dirname(keysFile), { recursive: true, mode: 0o700 });
|
|
52
|
+
const temporary = `${keysFile}.tmp`;
|
|
53
|
+
await writeFile(temporary, `${JSON.stringify(registry, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
54
|
+
await rename(temporary, keysFile);
|
|
55
|
+
}
|
|
56
|
+
const registryCache = new Map();
|
|
57
|
+
/**
|
|
58
|
+
* The parsed registry for resolution, cached by file mtime so revocations and
|
|
59
|
+
* rollovers are picked up without a restart. A missing file is an empty
|
|
60
|
+
* registry; an unreadable or malformed one resolves to null (fail closed).
|
|
61
|
+
*/
|
|
62
|
+
function readRegistryCached(keysFile) {
|
|
63
|
+
let mtimeMs = 0;
|
|
64
|
+
try {
|
|
65
|
+
mtimeMs = statSync(keysFile).mtimeMs;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
registryCache.set(keysFile, { mtimeMs: 0, registry: emptyRegistry() });
|
|
69
|
+
return emptyRegistry();
|
|
70
|
+
}
|
|
71
|
+
const cached = registryCache.get(keysFile);
|
|
72
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
73
|
+
return cached.registry;
|
|
74
|
+
let registry = null;
|
|
75
|
+
try {
|
|
76
|
+
registry = registrySchema.parse(JSON.parse(readFileSync(keysFile, 'utf8')));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
registry = null;
|
|
80
|
+
}
|
|
81
|
+
registryCache.set(keysFile, { mtimeMs, registry });
|
|
82
|
+
return registry;
|
|
83
|
+
}
|
|
84
|
+
/** The number of non-revoked keys; a missing or malformed registry counts zero. */
|
|
85
|
+
export function countActiveMcpKeys(keysFile) {
|
|
86
|
+
const registry = readRegistryCached(keysFile);
|
|
87
|
+
if (!registry)
|
|
88
|
+
return 0;
|
|
89
|
+
return registry.keys.filter((key) => key.revokedAt === undefined).length;
|
|
90
|
+
}
|
|
91
|
+
function findKey(registry, id) {
|
|
92
|
+
const key = registry.keys.find((entry) => entry.id === id);
|
|
93
|
+
if (!key)
|
|
94
|
+
throw new McpKeyNotFoundError(`Unknown MCP key ${id}.`);
|
|
95
|
+
return key;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Create a per-account key. The raw token is generated here, stored only as a
|
|
99
|
+
* SHA-256 hex digest, and returned exactly once.
|
|
100
|
+
*/
|
|
101
|
+
export async function addMcpKey(keysFile, input) {
|
|
102
|
+
const account = accountSchema.parse(input.account);
|
|
103
|
+
const label = (input.label ?? `${account} key`).trim();
|
|
104
|
+
if (!label)
|
|
105
|
+
throw new Error('Key label is required.');
|
|
106
|
+
if (label.length > 120)
|
|
107
|
+
throw new Error('Key label must be at most 120 characters.');
|
|
108
|
+
const registry = await loadRegistry(keysFile);
|
|
109
|
+
const id = newKeyId();
|
|
110
|
+
if (registry.keys.some((key) => key.id === id))
|
|
111
|
+
throw new Error(`MCP key id ${id} already exists.`);
|
|
112
|
+
const token = newToken();
|
|
113
|
+
const record = { id, label, account, tokenHash: hashToken(token), createdAt: new Date().toISOString() };
|
|
114
|
+
await writeRegistry(keysFile, { ...registry, keys: [...registry.keys, record] });
|
|
115
|
+
return { record, token };
|
|
116
|
+
}
|
|
117
|
+
/** All keys without their token hashes; a missing registry lists as empty. */
|
|
118
|
+
export async function listMcpKeys(keysFile) {
|
|
119
|
+
const registry = await loadRegistry(keysFile);
|
|
120
|
+
return registry.keys.map(({ tokenHash: _tokenHash, ...summary }) => summary);
|
|
121
|
+
}
|
|
122
|
+
/** Revoke a key by id. Revoking an already-revoked key is a no-op. */
|
|
123
|
+
export async function revokeMcpKey(keysFile, id) {
|
|
124
|
+
const registry = await loadRegistry(keysFile);
|
|
125
|
+
const key = findKey(registry, id);
|
|
126
|
+
if (key.revokedAt !== undefined)
|
|
127
|
+
return key;
|
|
128
|
+
const revoked = { ...key, revokedAt: new Date().toISOString() };
|
|
129
|
+
await writeRegistry(keysFile, { ...registry, keys: registry.keys.map((entry) => (entry.id === id ? revoked : entry)) });
|
|
130
|
+
return revoked;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Replace a key's token: same id, account, and label, revokedAt cleared, and a
|
|
134
|
+
* new raw token returned exactly once. The previous token stops resolving.
|
|
135
|
+
*/
|
|
136
|
+
export async function rollMcpKey(keysFile, id) {
|
|
137
|
+
const registry = await loadRegistry(keysFile);
|
|
138
|
+
const key = findKey(registry, id);
|
|
139
|
+
const token = newToken();
|
|
140
|
+
const rolled = { ...key, tokenHash: hashToken(token), revokedAt: undefined };
|
|
141
|
+
await writeRegistry(keysFile, { ...registry, keys: registry.keys.map((entry) => (entry.id === id ? rolled : entry)) });
|
|
142
|
+
return { record: rolled, token };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a raw bearer token to the key's account principal, or null when the
|
|
146
|
+
* token matches nothing. Comparison is timing-safe over the fixed-length
|
|
147
|
+
* SHA-256 hex digests. Fail closed: an unreadable or malformed registry, a
|
|
148
|
+
* revoked key, or a key whose account no longer exists all resolve to null.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveKeyPrincipal(keysFile, rawToken, resolveAccount) {
|
|
151
|
+
if (!rawToken)
|
|
152
|
+
return null;
|
|
153
|
+
const registry = readRegistryCached(keysFile);
|
|
154
|
+
if (!registry)
|
|
155
|
+
return null;
|
|
156
|
+
const received = Buffer.from(hashToken(rawToken), 'utf8');
|
|
157
|
+
for (const key of registry.keys) {
|
|
158
|
+
if (key.revokedAt !== undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const expected = Buffer.from(key.tokenHash, 'utf8');
|
|
161
|
+
// timingSafeEqual throws on length mismatch, so compare sizes first.
|
|
162
|
+
if (received.length !== expected.length || !timingSafeEqual(received, expected))
|
|
163
|
+
continue;
|
|
164
|
+
const scope = resolveAccount(key.account);
|
|
165
|
+
if (!scope)
|
|
166
|
+
return null;
|
|
167
|
+
return { account: key.account, role: scope.role, athleteIds: scope.athleteIds };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=mcp-keys.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AccessToken, AuthorizationCode, ClientIdMetadataDocument, GrantId, OAuthServerModel, RefreshToken } from 'mcp-oauth-server';
|
|
2
|
+
import type { OAuthClientInformationFull } from 'mcp-oauth-server';
|
|
3
|
+
export declare class FileOAuthServerModel implements OAuthServerModel {
|
|
4
|
+
private readonly root;
|
|
5
|
+
constructor(root: string);
|
|
6
|
+
private clientFile;
|
|
7
|
+
private codeFile;
|
|
8
|
+
private accessTokenFile;
|
|
9
|
+
private refreshTokenFile;
|
|
10
|
+
private cimdFile;
|
|
11
|
+
getClient(clientId: string): Promise<OAuthClientInformationFull | undefined>;
|
|
12
|
+
registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull>;
|
|
13
|
+
saveAuthorizationCode(code: AuthorizationCode, _client: OAuthClientInformationFull): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Read + invalidate in one operation, scoped to the client. The rename is
|
|
16
|
+
* the atomic claim: when several requests present the same code only one
|
|
17
|
+
* rename succeeds, and the losers see ENOENT and return undefined. A request
|
|
18
|
+
* from the wrong client consumes nothing.
|
|
19
|
+
*/
|
|
20
|
+
consumeAuthorizationCode(authorizationCode: string, clientId: string): Promise<AuthorizationCode | undefined>;
|
|
21
|
+
revokeAuthorizationCode(authorizationCode: string): Promise<void>;
|
|
22
|
+
saveAccessToken(token: AccessToken, _client: OAuthClientInformationFull): Promise<void>;
|
|
23
|
+
getAccessToken(accessToken: string): Promise<AccessToken | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* Revoke only when the token was issued to the requesting client; never
|
|
26
|
+
* throw when nothing matches (RFC 7009 §2.2 answers 200 either way).
|
|
27
|
+
*/
|
|
28
|
+
revokeAccessToken(accessToken: string, clientId: string): Promise<AccessToken | undefined>;
|
|
29
|
+
saveRefreshToken(token: RefreshToken, _client: OAuthClientInformationFull): Promise<void>;
|
|
30
|
+
revokeRefreshToken(refreshToken: string, clientId: string): Promise<RefreshToken | undefined>;
|
|
31
|
+
/** Refresh tokens rotate on every use, so consume is atomic + client-scoped like the code consume. */
|
|
32
|
+
consumeRefreshToken(refreshToken: string, clientId: string): Promise<RefreshToken | undefined>;
|
|
33
|
+
/** Sweep every access and refresh token belonging to one authorization grant. */
|
|
34
|
+
revokeGrant(grantId: GrantId): Promise<void>;
|
|
35
|
+
saveClientIdMetadataDocument(document: ClientIdMetadataDocument): Promise<void>;
|
|
36
|
+
getClientIdMetadataDocument(clientId: string): Promise<ClientIdMetadataDocument | undefined>;
|
|
37
|
+
private static tokenRecord;
|
|
38
|
+
private static accessToken;
|
|
39
|
+
private static refreshToken;
|
|
40
|
+
private consumeRecord;
|
|
41
|
+
private revokeTokenRecord;
|
|
42
|
+
}
|
|
43
|
+
/** The file-backed OAuth server model rooted at <catalogHome>/oauth. */
|
|
44
|
+
export declare function createFileOAuthServerModel(oauthRoot: string): OAuthServerModel;
|