Initial: config.saiden.dev worker with KV-backed secrets and files, project/env scoping
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
Generated
+3156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "config-saiden-dev",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250101.0",
|
||||||
|
"typescript": "^5.8",
|
||||||
|
"vitest": "^3.0",
|
||||||
|
"wrangler": "^4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Env } from "./types";
|
||||||
|
|
||||||
|
export function authenticate(request: Request, env: Env): Response | null {
|
||||||
|
const header = request.headers.get("Authorization");
|
||||||
|
if (!header) {
|
||||||
|
return json(401, { error: "Missing Authorization header" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scheme, token] = header.split(" ", 2);
|
||||||
|
if (scheme !== "Bearer" || !token) {
|
||||||
|
return json(401, { error: "Invalid Authorization format. Use: Bearer <key>" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token !== env.API_KEY) {
|
||||||
|
return json(403, { error: "Invalid API key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Env } from "./types";
|
||||||
|
import { authenticate, json } from "./auth";
|
||||||
|
import { getSecret, putSecret, deleteSecret, listSecrets } from "./routes/secrets";
|
||||||
|
import { getFile, putFile, deleteFile, listFiles } from "./routes/files";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
// Auth gate
|
||||||
|
const authError = authenticate(request, env);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method;
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// Parse: /:project/:env/secrets[/:key]
|
||||||
|
// Parse: /:project/:env/files[/:path+]
|
||||||
|
const match = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/(secrets|files)(?:\/(.+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
return json(404, {
|
||||||
|
error: "Not found",
|
||||||
|
usage: "/:project/:env/secrets[/:key] or /:project/:env/files[/:path]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, project, environment, resource, rest] = match;
|
||||||
|
|
||||||
|
// --- Secrets ---
|
||||||
|
if (resource === "secrets") {
|
||||||
|
const kv = env.CONFIG_SECRETS;
|
||||||
|
|
||||||
|
if (!rest) {
|
||||||
|
if (method === "GET") return listSecrets({ project, env: environment }, request, kv);
|
||||||
|
return json(405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { project, env: environment, key: rest };
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
return getSecret(params, kv);
|
||||||
|
case "PUT":
|
||||||
|
return putSecret(params, request, kv);
|
||||||
|
case "DELETE":
|
||||||
|
return deleteSecret(params, kv);
|
||||||
|
default:
|
||||||
|
return json(405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Files ---
|
||||||
|
if (resource === "files") {
|
||||||
|
const kv = env.CONFIG_FILES;
|
||||||
|
|
||||||
|
if (!rest) {
|
||||||
|
if (method === "GET") return listFiles({ project, env: environment }, request, kv);
|
||||||
|
return json(405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { project, env: environment, path: rest };
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
return getFile(params, kv);
|
||||||
|
case "PUT":
|
||||||
|
return putFile(params, request, kv);
|
||||||
|
case "DELETE":
|
||||||
|
return deleteFile(params, kv);
|
||||||
|
default:
|
||||||
|
return json(405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(404, { error: "Not found" });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Env, FileParams, FileMetadata, RouteParams } from "../types";
|
||||||
|
import { json } from "../auth";
|
||||||
|
|
||||||
|
const PREFIX = "file";
|
||||||
|
|
||||||
|
function kvKey(project: string, env: string, path: string): string {
|
||||||
|
return `${PREFIX}:${project}:${env}:${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvPrefix(project: string, env: string): string {
|
||||||
|
return `${PREFIX}:${project}:${env}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessContentType(path: string): string {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
json: "application/json",
|
||||||
|
toml: "application/toml",
|
||||||
|
yaml: "application/yaml",
|
||||||
|
yml: "application/yaml",
|
||||||
|
xml: "application/xml",
|
||||||
|
ini: "text/plain",
|
||||||
|
conf: "text/plain",
|
||||||
|
txt: "text/plain",
|
||||||
|
env: "text/plain",
|
||||||
|
sh: "text/x-shellscript",
|
||||||
|
ts: "text/typescript",
|
||||||
|
js: "text/javascript",
|
||||||
|
};
|
||||||
|
return map[ext ?? ""] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFile(params: FileParams, kv: KVNamespace): Promise<Response> {
|
||||||
|
const key = kvKey(params.project, params.env, params.path);
|
||||||
|
const { value, metadata } = await kv.getWithMetadata<FileMetadata>(key);
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return json(404, { error: "File not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(value, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": metadata?.contentType ?? "application/octet-stream",
|
||||||
|
"X-Updated-At": metadata?.updatedAt ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putFile(params: FileParams, request: Request, kv: KVNamespace): Promise<Response> {
|
||||||
|
const content = await request.text();
|
||||||
|
if (!content) {
|
||||||
|
return json(400, { error: "Empty body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
request.headers.get("Content-Type") ?? guessContentType(params.path);
|
||||||
|
|
||||||
|
const metadata: FileMetadata = {
|
||||||
|
contentType,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
size: content.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
await kv.put(kvKey(params.project, params.env, params.path), content, { metadata });
|
||||||
|
|
||||||
|
return json(200, {
|
||||||
|
path: params.path,
|
||||||
|
project: params.project,
|
||||||
|
env: params.env,
|
||||||
|
contentType,
|
||||||
|
size: content.length,
|
||||||
|
stored: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(params: FileParams, kv: KVNamespace): Promise<Response> {
|
||||||
|
await kv.delete(kvKey(params.project, params.env, params.path));
|
||||||
|
return json(200, { path: params.path, deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFiles(params: RouteParams, request: Request, kv: KVNamespace): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "100", 10), 1000);
|
||||||
|
|
||||||
|
const prefix = kvPrefix(params.project, params.env);
|
||||||
|
const result = await kv.list<FileMetadata>({ prefix, cursor, limit });
|
||||||
|
|
||||||
|
const files = result.keys.map((k) => ({
|
||||||
|
path: k.name.slice(prefix.length),
|
||||||
|
contentType: k.metadata?.contentType,
|
||||||
|
size: k.metadata?.size,
|
||||||
|
updatedAt: k.metadata?.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return json(200, {
|
||||||
|
project: params.project,
|
||||||
|
env: params.env,
|
||||||
|
files,
|
||||||
|
cursor: result.list_complete ? undefined : result.cursor,
|
||||||
|
complete: result.list_complete,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Env, SecretParams, RouteParams } from "../types";
|
||||||
|
import { json } from "../auth";
|
||||||
|
|
||||||
|
const PREFIX = "secret";
|
||||||
|
|
||||||
|
function kvKey(project: string, env: string, key: string): string {
|
||||||
|
return `${PREFIX}:${project}:${env}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvPrefix(project: string, env: string): string {
|
||||||
|
return `${PREFIX}:${project}:${env}:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSecret(params: SecretParams, kv: KVNamespace): Promise<Response> {
|
||||||
|
const value = await kv.get(kvKey(params.project, params.env, params.key));
|
||||||
|
if (value === null) {
|
||||||
|
return json(404, { error: "Secret not found" });
|
||||||
|
}
|
||||||
|
return json(200, { key: params.key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putSecret(params: SecretParams, request: Request, kv: KVNamespace): Promise<Response> {
|
||||||
|
const body = await request.json<{ value: string }>().catch(() => null);
|
||||||
|
if (!body || typeof body.value !== "string") {
|
||||||
|
return json(400, { error: "Body must be JSON with a 'value' string field" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await kv.put(kvKey(params.project, params.env, params.key), body.value);
|
||||||
|
return json(200, { key: params.key, project: params.project, env: params.env, stored: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSecret(params: SecretParams, kv: KVNamespace): Promise<Response> {
|
||||||
|
await kv.delete(kvKey(params.project, params.env, params.key));
|
||||||
|
return json(200, { key: params.key, deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSecrets(params: RouteParams, request: Request, kv: KVNamespace): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "100", 10), 1000);
|
||||||
|
|
||||||
|
const prefix = kvPrefix(params.project, params.env);
|
||||||
|
const result = await kv.list({ prefix, cursor, limit });
|
||||||
|
|
||||||
|
const keys = result.keys.map((k) => k.name.slice(prefix.length));
|
||||||
|
|
||||||
|
return json(200, {
|
||||||
|
project: params.project,
|
||||||
|
env: params.env,
|
||||||
|
keys,
|
||||||
|
cursor: result.list_complete ? undefined : result.cursor,
|
||||||
|
complete: result.list_complete,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Env {
|
||||||
|
CONFIG_SECRETS: KVNamespace;
|
||||||
|
CONFIG_FILES: KVNamespace;
|
||||||
|
API_KEY: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteParams {
|
||||||
|
project: string;
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretParams extends RouteParams {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileParams extends RouteParams {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResult {
|
||||||
|
keys: string[];
|
||||||
|
cursor?: string;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
contentType: string;
|
||||||
|
updatedAt: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name = "config-saiden-dev"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
{ pattern = "config.saiden.dev", custom_domain = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "CONFIG_SECRETS"
|
||||||
|
id = "425dd5e9d641479e8130d20b83fa0224"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "CONFIG_FILES"
|
||||||
|
id = "f2c224cbfce04852ba4616d51fa545b7"
|
||||||
Reference in New Issue
Block a user