@getfootgun/keys 0.0.0-stage → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/bin/footgun-keys.mjs +4 -0
- package/lib/check.d.mts +3 -0
- package/lib/check.mjs +14 -0
- package/lib/dispatch.d.mts +2 -0
- package/lib/dispatch.mjs +24 -0
- package/lib/envfile.d.mts +10 -0
- package/lib/envfile.mjs +104 -0
- package/lib/health.d.mts +17 -0
- package/lib/health.mjs +98 -0
- package/lib/launchd.d.mts +1 -0
- package/lib/launchd.mjs +28 -0
- package/lib/mcp.d.mts +25 -0
- package/lib/mcp.mjs +271 -0
- package/lib/native-host.mjs +7 -0
- package/lib/native.d.mts +15 -0
- package/lib/native.mjs +150 -0
- package/lib/paths.d.mts +4 -0
- package/lib/paths.mjs +36 -0
- package/lib/platform.d.mts +5 -0
- package/lib/platform.mjs +38 -0
- package/lib/protect.d.mts +6 -0
- package/lib/protect.mjs +63 -0
- package/lib/schedule.d.mts +11 -0
- package/lib/schedule.mjs +97 -0
- package/lib/server.d.mts +24 -0
- package/lib/server.mjs +105 -0
- package/lib/sessions.d.mts +19 -0
- package/lib/sessions.mjs +37 -0
- package/lib/setup.d.mts +22 -0
- package/lib/setup.mjs +226 -0
- package/lib/testcall.d.mts +11 -0
- package/lib/testcall.mjs +103 -0
- package/lib/vault.d.mts +26 -0
- package/lib/vault.mjs +89 -0
- package/package.json +1 -6
- package/shared/contract.d.mts +36 -0
- package/shared/contract.mjs +49 -0
- package/shared/extension-id.json +5 -0
- package/shared/extension-ids.d.mts +1 -0
- package/shared/extension-ids.mjs +13 -0
- package/shared/types.d.ts +60 -0
package/lib/vault.d.mts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface KeyStore {
|
|
2
|
+
get(account: string): Promise<string | null>;
|
|
3
|
+
set(account: string, value: string): Promise<void>;
|
|
4
|
+
delete(account: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export interface IndexEntry {
|
|
7
|
+
provider: string;
|
|
8
|
+
label: string;
|
|
9
|
+
envVar: string;
|
|
10
|
+
secret: boolean;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
projects: string[];
|
|
13
|
+
health: { ok: boolean; reason?: string; checkedAt: string } | null;
|
|
14
|
+
}
|
|
15
|
+
export interface Vault {
|
|
16
|
+
put(provider: string, label: string, envVar: string, value: string, secret: boolean): Promise<void>;
|
|
17
|
+
get(provider: string, label: string, envVar: string): Promise<string | null>;
|
|
18
|
+
list(): Promise<IndexEntry[]>;
|
|
19
|
+
forProvider(provider: string, label?: string): Promise<IndexEntry[]>;
|
|
20
|
+
addProject(provider: string, label: string, envVar: string, projectPath: string): Promise<void>;
|
|
21
|
+
setHealth(provider: string, label: string, envVar: string, health: NonNullable<IndexEntry["health"]>): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export function keychainStore(): KeyStore;
|
|
24
|
+
export function memoryStore(): KeyStore;
|
|
25
|
+
export function openVault(opts: { store: KeyStore; home?: string; platform?: NodeJS.Platform }): Vault;
|
|
26
|
+
export function tooLongFor(value: string, platform?: NodeJS.Platform): string | null;
|
package/lib/vault.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Entry } from "@napi-rs/keyring";
|
|
4
|
+
import { supportDir, ensureSupportDir, writePrivate } from "./paths.mjs";
|
|
5
|
+
|
|
6
|
+
const SERVICE = "footgun-keys";
|
|
7
|
+
const account = (provider, label, envVar) => `${provider}/${label}/${envVar}`;
|
|
8
|
+
|
|
9
|
+
export function keychainStore() {
|
|
10
|
+
const entry = (a) => new Entry(SERVICE, a);
|
|
11
|
+
return {
|
|
12
|
+
async get(a) { return entry(a).getPassword() ?? null; },
|
|
13
|
+
async set(a, value) { entry(a).setPassword(value); },
|
|
14
|
+
async delete(a) { entry(a).deletePassword(); },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function memoryStore() {
|
|
19
|
+
const m = new Map();
|
|
20
|
+
return {
|
|
21
|
+
async get(a) { return m.has(a) ? m.get(a) : null; },
|
|
22
|
+
async set(a, value) { m.set(a, value); },
|
|
23
|
+
async delete(a) { m.delete(a); },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Windows Credential Manager holds at most 2,560 bytes per credential, counted as UTF-16.
|
|
28
|
+
const WIN_MAX_BYTES = 2560;
|
|
29
|
+
|
|
30
|
+
// The fixed refusal message when value cannot be stored on this platform, else null. Never contains the value.
|
|
31
|
+
export function tooLongFor(value, platform = process.platform) {
|
|
32
|
+
return platform === "win32" && Buffer.byteLength(value, "utf16le") > WIN_MAX_BYTES
|
|
33
|
+
? "This value is too long for Windows Credential Manager."
|
|
34
|
+
: null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openVault({ store, home, platform = process.platform }) {
|
|
38
|
+
const file = join(supportDir(home), "index.json");
|
|
39
|
+
let queue = Promise.resolve();
|
|
40
|
+
|
|
41
|
+
async function read() {
|
|
42
|
+
try { return JSON.parse(await readFile(file, "utf8")); }
|
|
43
|
+
catch (e) { if (e.code === "ENOENT") return []; throw e; }
|
|
44
|
+
}
|
|
45
|
+
// Serialize read-modify-write so concurrent calls cannot lose updates.
|
|
46
|
+
function mutate(fn) {
|
|
47
|
+
const run = queue.then(async () => {
|
|
48
|
+
await ensureSupportDir(home);
|
|
49
|
+
const entries = await read();
|
|
50
|
+
await fn(entries);
|
|
51
|
+
await writePrivate(file, JSON.stringify(entries, null, 2));
|
|
52
|
+
});
|
|
53
|
+
queue = run.catch(() => {});
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
const find = (entries, p, l, e) => entries.find((x) => x.provider === p && x.label === l && x.envVar === e);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async put(provider, label, envVar, value, secret) {
|
|
60
|
+
const tooLong = tooLongFor(value, platform);
|
|
61
|
+
if (tooLong) throw new Error(tooLong);
|
|
62
|
+
await store.set(account(provider, label, envVar), value);
|
|
63
|
+
await mutate((entries) => {
|
|
64
|
+
if (!find(entries, provider, label, envVar)) {
|
|
65
|
+
entries.push({ provider, label, envVar, secret, createdAt: new Date().toISOString(), projects: [], health: null });
|
|
66
|
+
} else {
|
|
67
|
+
find(entries, provider, label, envVar).secret = secret;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
get(provider, label, envVar) { return store.get(account(provider, label, envVar)); },
|
|
72
|
+
async list() { return read(); },
|
|
73
|
+
async forProvider(provider, label = "default") {
|
|
74
|
+
return (await read()).filter((x) => x.provider === provider && x.label === label);
|
|
75
|
+
},
|
|
76
|
+
addProject(provider, label, envVar, projectPath) {
|
|
77
|
+
return mutate((entries) => {
|
|
78
|
+
const e = find(entries, provider, label, envVar);
|
|
79
|
+
if (e && !e.projects.includes(projectPath)) e.projects.push(projectPath);
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
setHealth(provider, label, envVar, health) {
|
|
83
|
+
return mutate((entries) => {
|
|
84
|
+
const e = find(entries, provider, label, envVar);
|
|
85
|
+
if (e) e.health = health;
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@getfootgun/keys",
|
|
3
|
-
"version": "0.0.0-stage",
|
|
4
|
-
"stub": true,
|
|
5
|
-
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
-
}
|
|
1
|
+
{ "name": "@getfootgun/keys", "version": "0.2.0-rc.1", "description": "Footgun Keys: guided API key setup that saves keys to the Mac Keychain or Windows Credential Manager.", "bin": { "footgun-keys": "bin/footgun-keys.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "shared", "README.md"], "dependencies": { "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "2.1.2", "zod": "4.6.7" }, "engines": { "node": ">=20" } }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { KeysEntitlement, GuideBody } from "./types";
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_TYPES: readonly ["hello", "guide", "session", "save"];
|
|
4
|
+
export const SESSION_RE: RegExp;
|
|
5
|
+
export const MAX_VALUE: number;
|
|
6
|
+
export const LABEL_RE: RegExp;
|
|
7
|
+
export const FREE_GUIDES: readonly string[];
|
|
8
|
+
|
|
9
|
+
export type HelloRequest = { type: "hello" };
|
|
10
|
+
export type GuideRequest = { type: "guide"; slug: string };
|
|
11
|
+
export type SessionRequest = { type: "session"; id: string };
|
|
12
|
+
export type SaveRequest = {
|
|
13
|
+
type: "save";
|
|
14
|
+
session?: string;
|
|
15
|
+
guide: string;
|
|
16
|
+
version: number;
|
|
17
|
+
label: string;
|
|
18
|
+
values: { envVar: string; value: string }[];
|
|
19
|
+
final: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type KeysRequest = HelloRequest | GuideRequest | SessionRequest | SaveRequest;
|
|
22
|
+
|
|
23
|
+
export type HelloResponse = { ok: true; helperVersion: string; signedIn: boolean; entitlement: KeysEntitlement | null };
|
|
24
|
+
export type GuideResponse =
|
|
25
|
+
| { ok: true; guide: GuideBody; version: number; status: string }
|
|
26
|
+
| { ok: false; error: string; upgradeUrl?: string };
|
|
27
|
+
export type SessionResponse =
|
|
28
|
+
| { ok: true; provider: string; envVars: string[] }
|
|
29
|
+
| { ok: false; error: "unknown session" | "expired" };
|
|
30
|
+
export type SaveResponse =
|
|
31
|
+
| { ok: true; saved: string[]; wrote?: { file: string; addedGitignore: boolean }; test?: { ok: boolean; reason?: string } }
|
|
32
|
+
| { ok: false; error: string };
|
|
33
|
+
export type BadMessageResponse = { ok: false; error: "bad message" };
|
|
34
|
+
export type KeysResponse = HelloResponse | GuideResponse | SessionResponse | SaveResponse | BadMessageResponse;
|
|
35
|
+
|
|
36
|
+
export function validateRequest(msg: unknown): { ok: true; req: KeysRequest } | { ok: false };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Native messaging contract between the Chrome extension and the Mac helper.
|
|
2
|
+
|
|
3
|
+
export const MESSAGE_TYPES = ["hello", "guide", "session", "save"];
|
|
4
|
+
export const SESSION_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
5
|
+
export const MAX_VALUE = 4096;
|
|
6
|
+
// Guides free on every plan. Same list as lib/keys/guide.ts (a test keeps them equal).
|
|
7
|
+
export const FREE_GUIDES = ["openai", "anthropic", "google-ai-studio", "stripe", "resend", "supabase", "github", "vercel"];
|
|
8
|
+
|
|
9
|
+
const ENV_VAR = /^[A-Z][A-Z0-9_]*$/;
|
|
10
|
+
export const LABEL_RE = /^[A-Za-z0-9 ._-]{1,64}$/;
|
|
11
|
+
const SLUG = /^[a-z0-9-]+$/;
|
|
12
|
+
const KEYS = {
|
|
13
|
+
hello: [[], []],
|
|
14
|
+
guide: [["slug"], []],
|
|
15
|
+
session: [["id"], []],
|
|
16
|
+
save: [["guide", "version", "label", "values", "final"], ["session"]],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
function validValue(v) {
|
|
22
|
+
return isObject(v) && Object.keys(v).length === 2 && typeof v.envVar === "string" && ENV_VAR.test(v.envVar)
|
|
23
|
+
&& typeof v.value === "string" && v.value.length > 0 && v.value.length <= MAX_VALUE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateRequest(msg) {
|
|
27
|
+
if (!isObject(msg) || typeof msg.type !== "string" || !MESSAGE_TYPES.includes(msg.type)) return { ok: false };
|
|
28
|
+
const [required, optional] = KEYS[msg.type];
|
|
29
|
+
const keys = Object.keys(msg).filter((k) => k !== "type");
|
|
30
|
+
if (keys.some((k) => !required.includes(k) && !optional.includes(k))) return { ok: false };
|
|
31
|
+
if (required.some((k) => !Object.hasOwn(msg, k))) return { ok: false };
|
|
32
|
+
switch (msg.type) {
|
|
33
|
+
case "guide":
|
|
34
|
+
if (typeof msg.slug !== "string" || msg.slug.length > 100 || !SLUG.test(msg.slug)) return { ok: false };
|
|
35
|
+
break;
|
|
36
|
+
case "session":
|
|
37
|
+
if (typeof msg.id !== "string" || !SESSION_RE.test(msg.id)) return { ok: false };
|
|
38
|
+
break;
|
|
39
|
+
case "save":
|
|
40
|
+
if (Object.hasOwn(msg, "session") && (typeof msg.session !== "string" || !SESSION_RE.test(msg.session))) return { ok: false };
|
|
41
|
+
if (typeof msg.guide !== "string" || msg.guide.length > 100 || !SLUG.test(msg.guide)) return { ok: false };
|
|
42
|
+
if (!Number.isInteger(msg.version) || msg.version < 1) return { ok: false };
|
|
43
|
+
if (typeof msg.label !== "string" || !LABEL_RE.test(msg.label)) return { ok: false };
|
|
44
|
+
if (!Array.isArray(msg.values) || !msg.values.every(validValue)) return { ok: false };
|
|
45
|
+
if (typeof msg.final !== "boolean") return { ok: false };
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, req: msg };
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXTENSION_IDS: string[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Node-only: the Chrome extension ids the helper trusts. Kept out of contract.mjs so that file stays browser-safe.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
function loadIds() {
|
|
5
|
+
let ids = [];
|
|
6
|
+
try {
|
|
7
|
+
const j = JSON.parse(readFileSync(new URL("./extension-id.json", import.meta.url), "utf8"));
|
|
8
|
+
if (Array.isArray(j.ids)) ids = j.ids.filter((x) => typeof x === "string");
|
|
9
|
+
} catch { /* file absent until the extension is built */ }
|
|
10
|
+
if (process.env.FOOTGUN_KEYS_EXTENSION_ID) ids.push(process.env.FOOTGUN_KEYS_EXTENSION_ID);
|
|
11
|
+
return ids;
|
|
12
|
+
}
|
|
13
|
+
export const EXTENSION_IDS = loadIds();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Plain TypeScript copies of server types, so the helper and the extension need no zod.
|
|
2
|
+
// Sources: KeysEntitlement and KeysTool from lib/keys/entitlement.ts, GuideBody from lib/keys/guide.ts (guideBodySchema).
|
|
3
|
+
// Keep in sync by hand when those change.
|
|
4
|
+
|
|
5
|
+
export type KeysTool = "keys_list" | "keys_write_env" | "keys_request" | "keys_check" | "keys_guides";
|
|
6
|
+
|
|
7
|
+
export type KeysEntitlement = {
|
|
8
|
+
plan: "free" | "pro" | "team";
|
|
9
|
+
allGuides: boolean;
|
|
10
|
+
tools: KeysTool[];
|
|
11
|
+
health: boolean;
|
|
12
|
+
launchFree: boolean;
|
|
13
|
+
checkedAt: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type GuideStep = {
|
|
17
|
+
id: string;
|
|
18
|
+
instruction: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
target?: { selectors: string[]; text?: string };
|
|
21
|
+
trap?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GuideCapture = {
|
|
25
|
+
step: string;
|
|
26
|
+
pattern: string;
|
|
27
|
+
envVar: string;
|
|
28
|
+
label: string;
|
|
29
|
+
secret: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GuideTestAuth =
|
|
33
|
+
| { type: "bearer"; envVar: string }
|
|
34
|
+
| { type: "header"; name: string; envVar: string }
|
|
35
|
+
| { type: "basic"; userEnvVar: string; passEnvVar: string }
|
|
36
|
+
| { type: "query"; name: string; envVar: string }
|
|
37
|
+
| { type: "none" };
|
|
38
|
+
|
|
39
|
+
export type GuideTest = {
|
|
40
|
+
method: "GET" | "POST";
|
|
41
|
+
url: string;
|
|
42
|
+
auth: GuideTestAuth;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
body?: string;
|
|
45
|
+
contentType?: "json" | "form";
|
|
46
|
+
expectStatus: number;
|
|
47
|
+
failures: Record<string, "revoked" | "out_of_credit" | "rate_limited" | "unreachable">;
|
|
48
|
+
failIfJsonHas?: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type GuideBody = {
|
|
52
|
+
slug: string;
|
|
53
|
+
name: string;
|
|
54
|
+
tier: "free" | "pro";
|
|
55
|
+
docsUrl: string;
|
|
56
|
+
domains: string[];
|
|
57
|
+
steps: GuideStep[];
|
|
58
|
+
captures: GuideCapture[];
|
|
59
|
+
test: GuideTest | null;
|
|
60
|
+
};
|