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