@getfootgun/keys 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,11 @@
1
+ import type { GuideBody } from "../shared/types";
2
+
3
+ export function runTest(
4
+ guide: GuideBody,
5
+ values: Record<string, string>,
6
+ opts?: { fetch?: typeof fetch; timeoutMs?: number },
7
+ ): Promise<{
8
+ ok: boolean;
9
+ reason?: "revoked" | "out_of_credit" | "rate_limited" | "unreachable" | "unexpected";
10
+ status?: number;
11
+ }>;
@@ -0,0 +1,103 @@
1
+ // The one request that may carry a key value off the machine, and only to the provider's own host.
2
+ // Results and errors never include a key value.
3
+
4
+ // Same rule as lib/keys/guide.ts: last two labels.
5
+ const registrable = (host) => host.split(".").slice(-2).join(".");
6
+
7
+ const ownHost = (guide, url) => {
8
+ try {
9
+ const own = new Set([...guide.domains, new URL(guide.docsUrl).hostname].map(registrable));
10
+ return own.has(registrable(new URL(url).hostname));
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ const BAD = { ok: false, reason: "unexpected" };
17
+
18
+ // Same list as lib/keys/guide.ts; checked again here in case a guide skipped validation.
19
+ const RESERVED = new Set(["authorization", "cookie", "host", "content-type", "content-length", "proxy-authorization"]);
20
+ const CONTENT_TYPES = { json: "application/json", form: "application/x-www-form-urlencoded" };
21
+
22
+ export async function runTest(guide, values, opts = {}) {
23
+ const t = guide.test;
24
+ if (!t) return { ok: true };
25
+ if (!ownHost(guide, t.url)) return { ...BAD };
26
+
27
+ const a = t.auth;
28
+ const headers = {};
29
+ let url;
30
+ try {
31
+ url = new URL(t.url);
32
+ } catch {
33
+ return { ...BAD };
34
+ }
35
+ if (url.protocol !== "https:") return { ...BAD };
36
+ const need = (name) => (typeof values[name] === "string" && values[name] !== "" ? values[name] : null);
37
+
38
+ if (a.type === "bearer") {
39
+ const v = need(a.envVar);
40
+ if (v === null) return { ...BAD };
41
+ headers.Authorization = `Bearer ${v}`;
42
+ } else if (a.type === "header") {
43
+ const v = need(a.envVar);
44
+ if (v === null) return { ...BAD };
45
+ headers[a.name] = v;
46
+ } else if (a.type === "basic") {
47
+ const u = need(a.userEnvVar);
48
+ const p = need(a.passEnvVar);
49
+ if (u === null || p === null) return { ...BAD };
50
+ headers.Authorization = "Basic " + Buffer.from(`${u}:${p}`).toString("base64");
51
+ } else if (a.type === "query") {
52
+ const v = need(a.envVar);
53
+ if (v === null) return { ...BAD };
54
+ url.searchParams.set(a.name, v);
55
+ } else if (a.type !== "none") {
56
+ return { ...BAD };
57
+ }
58
+
59
+ // Fixed extra headers never replace auth or the ones set here.
60
+ const taken = new Set(Object.keys(headers).map((h) => h.toLowerCase()));
61
+ for (const [name, value] of Object.entries(t.headers ?? {})) {
62
+ const n = name.toLowerCase();
63
+ if (taken.has(n) || RESERVED.has(n)) continue;
64
+ headers[name] = value;
65
+ }
66
+ let body;
67
+ if (t.body !== undefined) {
68
+ if (t.method !== "POST" || !CONTENT_TYPES[t.contentType]) return { ...BAD };
69
+ headers["Content-Type"] = CONTENT_TYPES[t.contentType];
70
+ body = t.body;
71
+ }
72
+
73
+ const doFetch = opts.fetch ?? globalThis.fetch;
74
+ const ctl = new AbortController();
75
+ const timer = setTimeout(() => ctl.abort(), opts.timeoutMs ?? 10000);
76
+ let status;
77
+ let reply = null;
78
+ try {
79
+ const res = await doFetch(url.toString(), { method: t.method, headers, body, signal: ctl.signal, redirect: "manual" });
80
+ status = res.status;
81
+ if (status === t.expectStatus && t.failIfJsonHas) reply = await res.text();
82
+ } catch {
83
+ return { ok: false, reason: "unreachable" };
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+
88
+ if (status === t.expectStatus) {
89
+ if (!t.failIfJsonHas) return { ok: true };
90
+ // The reply is read only to look for the named keys; nothing from it is returned.
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(reply);
94
+ } catch {
95
+ return { ok: false, reason: "unexpected", status };
96
+ }
97
+ if (parsed === null || typeof parsed !== "object") return { ok: false, reason: "unexpected", status };
98
+ const hit = t.failIfJsonHas.some((k) => parsed[k] !== undefined && parsed[k] !== null);
99
+ return hit ? { ok: false, reason: "revoked", status } : { ok: true };
100
+ }
101
+ const reason = t.failures[String(status)];
102
+ return { ok: false, reason: reason ?? "unexpected", status };
103
+ }
@@ -0,0 +1,25 @@
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 }): Vault;
package/lib/vault.mjs ADDED
@@ -0,0 +1,77 @@
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
+ export function openVault({ store, home }) {
28
+ const file = join(supportDir(home), "index.json");
29
+ let queue = Promise.resolve();
30
+
31
+ async function read() {
32
+ try { return JSON.parse(await readFile(file, "utf8")); }
33
+ catch (e) { if (e.code === "ENOENT") return []; throw e; }
34
+ }
35
+ // Serialize read-modify-write so concurrent calls cannot lose updates.
36
+ function mutate(fn) {
37
+ const run = queue.then(async () => {
38
+ await ensureSupportDir(home);
39
+ const entries = await read();
40
+ await fn(entries);
41
+ await writePrivate(file, JSON.stringify(entries, null, 2));
42
+ });
43
+ queue = run.catch(() => {});
44
+ return run;
45
+ }
46
+ const find = (entries, p, l, e) => entries.find((x) => x.provider === p && x.label === l && x.envVar === e);
47
+
48
+ return {
49
+ async put(provider, label, envVar, value, secret) {
50
+ await store.set(account(provider, label, envVar), value);
51
+ await mutate((entries) => {
52
+ if (!find(entries, provider, label, envVar)) {
53
+ entries.push({ provider, label, envVar, secret, createdAt: new Date().toISOString(), projects: [], health: null });
54
+ } else {
55
+ find(entries, provider, label, envVar).secret = secret;
56
+ }
57
+ });
58
+ },
59
+ get(provider, label, envVar) { return store.get(account(provider, label, envVar)); },
60
+ async list() { return read(); },
61
+ async forProvider(provider, label = "default") {
62
+ return (await read()).filter((x) => x.provider === provider && x.label === label);
63
+ },
64
+ addProject(provider, label, envVar, projectPath) {
65
+ return mutate((entries) => {
66
+ const e = find(entries, provider, label, envVar);
67
+ if (e && !e.projects.includes(projectPath)) e.projects.push(projectPath);
68
+ });
69
+ },
70
+ setHealth(provider, label, envVar, health) {
71
+ return mutate((entries) => {
72
+ const e = find(entries, provider, label, envVar);
73
+ if (e) e.health = health;
74
+ });
75
+ },
76
+ };
77
+ }
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.1.0", "description": "Footgun Keys: guided API key setup that saves keys to your Mac Keychain.", "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,5 @@
1
+ {
2
+ "ids": [
3
+ "cippejafibclbiifcljjikkpppfelhpe"
4
+ ]
5
+ }
@@ -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
+ };