From 4c6271a21ac09fb891b6716d975bdbc0d9b9c671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=A2=E5=AD=90?= Date: Mon, 20 Apr 2026 13:02:11 +0000 Subject: [PATCH] =?UTF-8?q?init:=20config=20service=20=E2=80=94=20layered?= =?UTF-8?q?=20KV=20store=20with=20CF=20Worker=20+=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CF Worker with shared/personal scope layering - Python CLI client (cfg) with sync/get/set/list/delete - Agent registration script - Auth via bearer token, sha256 hash lookup --- README.md | 54 +++++++++++- cli/cfg | 166 +++++++++++++++++++++++++++++++++++++ scripts/register_agent.py | 43 ++++++++++ worker/src/index.ts | 168 ++++++++++++++++++++++++++++++++++++++ worker/wrangler.toml | 7 ++ 5 files changed, 436 insertions(+), 2 deletions(-) create mode 100755 cli/cfg create mode 100644 scripts/register_agent.py create mode 100644 worker/src/index.ts create mode 100644 worker/wrangler.toml diff --git a/README.md b/README.md index b1f4482..29c1692 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,53 @@ -# config-service +# Config Service -Layered KV config service with shared/personal scopes — CF Worker + KV \ No newline at end of file +Layered KV config store with scope-based override. Built on Cloudflare Workers + KV. + +## Concept + +Like git config's `system → global → local` layering: + +- **shared** — team-wide config (e.g. `CF_ACCOUNT_ID`, `AWS_REGION`) +- **personal** — per-agent overrides (e.g. `GITEA_TOKEN`, `GH_TOKEN`) + +Read: personal wins over shared. Write: must specify scope. + +## Auth + +Each agent has a token. The service stores `sha256(token) → agent_id` mappings. +Agents can read/write their own personal scope and read (but not write) the shared scope. +Shared scope writes require an admin token. + +## API + +``` +GET /config/:key → returns personal value, fallback to shared +GET /config?scope=shared → list all shared keys +GET /config?scope=personal → list all personal keys +PUT /config/:key → write to personal scope (default) +PUT /config/:key?scope=shared → write to shared scope (admin only) +DELETE /config/:key → delete from personal scope +DELETE /config/:key?scope=shared → delete from shared (admin only) +POST /config/sync → returns all resolved keys (personal over shared) +``` + +Auth header: `Authorization: Bearer ` + +## Storage Layout (KV) + +``` +auth: → { "agent_id": "tuanzi", "role": "agent|admin" } +shared: → { "value": "...", "updated_at": "..." } +personal:: → { "value": "...", "updated_at": "..." } +``` + +## CLI + +```bash +cfg get # read (personal > shared) +cfg set # write to personal +cfg set --shared # write to shared (admin) +cfg list # list all resolved +cfg list --scope shared # list shared only +cfg sync # sync all to local cache +cfg delete # delete from personal +``` diff --git a/cli/cfg b/cli/cfg new file mode 100755 index 0000000..968b4dd --- /dev/null +++ b/cli/cfg @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""cfg — CLI client for the config service. + +Usage: + cfg get Read a key (personal > shared) + cfg set Write to personal scope + cfg set --shared Write to shared scope (admin) + cfg list [--scope shared|personal] List keys + cfg sync Sync all to local cache + cfg delete [--shared] Delete a key + cfg token Save auth token + +Config: ~/.config/cfg/config.json +Cache: ~/.config/cfg/cache.json +""" + +import json +import os +import sys +import urllib.request +import urllib.error +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "cfg" +CONFIG_FILE = CONFIG_DIR / "config.json" +CACHE_FILE = CONFIG_DIR / "cache.json" + +DEFAULT_ENDPOINT = "https://config-service.oc-fleet.workers.dev" + + +def load_config() -> dict: + if CONFIG_FILE.exists(): + return json.loads(CONFIG_FILE.read_text()) + return {} + + +def save_config(cfg: dict): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + + +def get_token() -> str: + cfg = load_config() + token = cfg.get("token") or os.environ.get("CFG_TOKEN") + if not token: + print("No token configured. Run: cfg token ", file=sys.stderr) + sys.exit(1) + return token + + +def get_endpoint() -> str: + cfg = load_config() + return cfg.get("endpoint", DEFAULT_ENDPOINT) + + +def api(method: str, path: str, body: dict | None = None) -> dict: + url = f"{get_endpoint()}{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {get_token()}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + result = json.loads(e.read()) + print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr) + sys.exit(1) + + +def cmd_get(args: list[str]): + if not args: + print("Usage: cfg get ", file=sys.stderr) + sys.exit(1) + result = api("GET", f"/config/{args[0]}") + print(result["value"]) + + +def cmd_set(args: list[str]): + shared = "--shared" in args + args = [a for a in args if a != "--shared"] + if len(args) < 2: + print("Usage: cfg set [--shared] ", file=sys.stderr) + sys.exit(1) + key, value = args[0], args[1] + scope = "shared" if shared else "personal" + api("PUT", f"/config/{key}?scope={scope}", {"value": value}) + print(f"✓ Set {key} ({scope})") + + +def cmd_delete(args: list[str]): + shared = "--shared" in args + args = [a for a in args if a != "--shared"] + if not args: + print("Usage: cfg delete [--shared]", file=sys.stderr) + sys.exit(1) + scope = "shared" if shared else "personal" + api("DELETE", f"/config/{args[0]}?scope={scope}") + print(f"✓ Deleted {args[0]} ({scope})") + + +def cmd_list(args: list[str]): + scope = None + if "--scope" in args: + idx = args.index("--scope") + if idx + 1 < len(args): + scope = args[idx + 1] + path = "/config" + (f"?scope={scope}" if scope else "") + result = api("GET", path) + if "keys" in result: + for k in sorted(result["keys"]): + print(f" {k}") + elif "secrets" in result: + for k, v in sorted(result["secrets"].items()): + print(f" {k:<40} ({v['scope']})") + + +def cmd_sync(args: list[str]): + result = api("POST", "/config/sync") + # Save to cache + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + cache = { + "secrets": {k: {"value": v["value"], "updatedAt": v["updated_at"]} for k, v in result["secrets"].items()}, + "lastSync": result.get("agent_id", ""), + } + CACHE_FILE.write_text(json.dumps(cache, indent=2)) + print(f"✓ Synced {len(result['secrets'])} keys") + + +def cmd_token(args: list[str]): + if not args: + print("Usage: cfg token ", file=sys.stderr) + sys.exit(1) + cfg = load_config() + cfg["token"] = args[0] + save_config(cfg) + print("✓ Token saved") + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(0) + + cmd = sys.argv[1] + args = sys.argv[2:] + + commands = { + "get": cmd_get, + "set": cmd_set, + "delete": cmd_delete, + "list": cmd_list, + "sync": cmd_sync, + "token": cmd_token, + } + + if cmd not in commands: + print(f"Unknown command: {cmd}", file=sys.stderr) + print(__doc__) + sys.exit(1) + + commands[cmd](args) + + +if __name__ == "__main__": + main() diff --git a/scripts/register_agent.py b/scripts/register_agent.py new file mode 100644 index 0000000..23e3521 --- /dev/null +++ b/scripts/register_agent.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Register an agent token in the config service KV. + +Usage: python3 register_agent.py [token] + +If token is omitted, a random one is generated. +Outputs the KV entry to add via wrangler CLI. +""" + +import hashlib +import json +import secrets +import sys + + +def main(): + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [token]") + print(" role: agent or admin") + sys.exit(1) + + agent_id = sys.argv[1] + role = sys.argv[2] + token = sys.argv[3] if len(sys.argv) > 3 else secrets.token_urlsafe(32) + + if role not in ("agent", "admin"): + print("Role must be 'agent' or 'admin'", file=sys.stderr) + sys.exit(1) + + token_hash = hashlib.sha256(token.encode()).hexdigest() + entry = json.dumps({"agent_id": agent_id, "role": role}) + + print(f"Agent ID: {agent_id}") + print(f"Role: {role}") + print(f"Token: {token}") + print(f"Token hash: {token_hash}") + print() + print("Add to KV:") + print(f' wrangler kv key put --binding CONFIG_KV "auth:{token_hash}" \'{entry}\'') + + +if __name__ == "__main__": + main() diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..23f3d0f --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,168 @@ +export interface Env { + CONFIG_KV: KVNamespace; +} + +interface AuthInfo { + agent_id: string; + role: "agent" | "admin"; +} + +interface KVEntry { + value: string; + updated_at: string; +} + +async function sha256(text: string): Promise { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function authenticate(request: Request, kv: KVNamespace): Promise { + const auth = request.headers.get("Authorization"); + if (!auth?.startsWith("Bearer ")) return null; + const token = auth.slice(7); + const hash = await sha256(token); + const entry = await kv.get(`auth:${hash}`, "json"); + return entry as AuthInfo | null; +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function err(message: string, status = 400): Response { + return json({ error: message }, status); +} + +// GET /config/:key — read with personal > shared fallback +async function handleGet(key: string, auth: AuthInfo, kv: KVNamespace): Promise { + // Try personal first + const personal = await kv.get(`personal:${auth.agent_id}:${key}`, "json"); + if (personal) { + return json({ key, value: personal.value, scope: "personal", updated_at: personal.updated_at }); + } + // Fallback to shared + const shared = await kv.get(`shared:${key}`, "json"); + if (shared) { + return json({ key, value: shared.value, scope: "shared", updated_at: shared.updated_at }); + } + return err("not found", 404); +} + +// PUT /config/:key — write to specified scope +async function handlePut( + key: string, + scope: string, + auth: AuthInfo, + kv: KVNamespace, + request: Request +): Promise { + const body = await request.json<{ value: string }>(); + if (!body?.value && body?.value !== "") return err("missing 'value' in body"); + + const entry: KVEntry = { value: body.value, updated_at: new Date().toISOString() }; + + if (scope === "shared") { + if (auth.role !== "admin") return err("admin required for shared scope", 403); + await kv.put(`shared:${key}`, JSON.stringify(entry)); + } else { + await kv.put(`personal:${auth.agent_id}:${key}`, JSON.stringify(entry)); + } + return json({ key, scope, status: "ok" }); +} + +// DELETE /config/:key +async function handleDelete(key: string, scope: string, auth: AuthInfo, kv: KVNamespace): Promise { + if (scope === "shared") { + if (auth.role !== "admin") return err("admin required for shared scope", 403); + await kv.delete(`shared:${key}`); + } else { + await kv.delete(`personal:${auth.agent_id}:${key}`); + } + return json({ key, scope, status: "deleted" }); +} + +// GET /config?scope=... — list keys +async function handleList(scope: string | null, auth: AuthInfo, kv: KVNamespace): Promise { + if (scope === "shared") { + const list = await kv.list({ prefix: "shared:" }); + const keys = list.keys.map((k) => k.name.replace("shared:", "")); + return json({ scope: "shared", keys }); + } + if (scope === "personal") { + const list = await kv.list({ prefix: `personal:${auth.agent_id}:` }); + const keys = list.keys.map((k) => k.name.replace(`personal:${auth.agent_id}:`, "")); + return json({ scope: "personal", keys }); + } + // Default: return all resolved keys + return handleSync(auth, kv); +} + +// POST /config/sync — return all resolved key-values (personal > shared) +async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise { + const resolved: Record = {}; + + // Load shared first + const sharedList = await kv.list({ prefix: "shared:" }); + for (const k of sharedList.keys) { + const key = k.name.replace("shared:", ""); + const entry = await kv.get(k.name, "json"); + if (entry) resolved[key] = { value: entry.value, scope: "shared", updated_at: entry.updated_at }; + } + + // Personal overrides + const personalList = await kv.list({ prefix: `personal:${auth.agent_id}:` }); + for (const k of personalList.keys) { + const key = k.name.replace(`personal:${auth.agent_id}:`, ""); + const entry = await kv.get(k.name, "json"); + if (entry) resolved[key] = { value: entry.value, scope: "personal", updated_at: entry.updated_at }; + } + + return json({ agent_id: auth.agent_id, secrets: resolved }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + + // Health check + if (path === "/health") return json({ status: "ok" }); + + // Auth required for everything else + const auth = await authenticate(request, env.CONFIG_KV); + if (!auth) return err("unauthorized", 401); + + const scope = url.searchParams.get("scope") || "personal"; + + // POST /config/sync + if (path === "/config/sync" && method === "POST") { + return handleSync(auth, env.CONFIG_KV); + } + + // GET /config — list + if (path === "/config" && method === "GET") { + return handleList(url.searchParams.get("scope"), auth, env.CONFIG_KV); + } + + // /config/:key + const keyMatch = path.match(/^\/config\/(.+)$/); + if (!keyMatch) return err("not found", 404); + const key = decodeURIComponent(keyMatch[1]); + + switch (method) { + case "GET": + return handleGet(key, auth, env.CONFIG_KV); + case "PUT": + return handlePut(key, scope, auth, env, request); + case "DELETE": + return handleDelete(key, scope, auth, env.CONFIG_KV); + default: + return err("method not allowed", 405); + } + }, +}; diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..261c100 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,7 @@ +name = "config-service" +main = "src/index.ts" +compatibility_date = "2024-12-01" + +[[kv_namespaces]] +binding = "CONFIG_KV" +id = ""