├── .eslintrc ├── .gitignore ├── .npmrc ├── .nvmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── packages └── auth-token-gen │ ├── .gitignore │ ├── .vscode │ └── extensions.json │ ├── README.md │ ├── index.html │ ├── master.css.js │ ├── package.json │ ├── src │ ├── App.vue │ ├── assets │ │ └── vue.svg │ ├── components │ │ └── PermissionItem.vue │ ├── main.ts │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── polyfill └── node-crypto.ts ├── src ├── index.ts └── middleware │ ├── auth.test.ts │ ├── auth.ts │ └── namespace.ts ├── tsconfig.json ├── tsup.config.ts └── wrangler.toml /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "ignorePatterns": "**/*.json", 4 | "rules": { 5 | "no-console": "off", 6 | "curly": "off", 7 | "@typescript-eslint/brace-style": ["error", "1tbs", { "allowSingleLine": true }], 8 | "@typescript-eslint/no-unused-vars": "warn" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | .DS_Store 5 | *.log 6 | *.tgz 7 | logs 8 | wrangler.dev.toml 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | enable-pre-post-scripts=true 3 | use-node-version=16.13.0 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.19.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 SerKo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-kv-server 2 | 3 | One-click deploy a KV server to Cloudflare Workers. It is similar to [Cloudflare API for KV](https://api.cloudflare.com/#workers-kv-namespace-list-a-namespace-s-keys) but you just need a self-generate JWT token instead of using Cloudflare auth key and email. You can also customize each JWT token with expiry date, action permissions or whitelist key patterns. 4 | 5 | ## Deploy to Cloudflare Workers 6 | 7 | 1. Clone this repo 8 | 2. Config `wrangler.toml` and set `name`, `kv_namespaces` 9 | 4. Run `wrangler login` and `wrangler publish` 10 | 11 | ### Auth Token 12 | 13 | Its optional but recommend to set a Secret to protect your endpoint. You can either set `AUTH_SECRET` in `wrangler.toml` or set a secret binding in Cloudflare Workers dashboard to increase security. 14 | 15 | After setting a Secret, you can use [Online Token Generator](https://cf-kv-server-token-gen.pages.dev/) to generate token or build it yourself (`./packages/auth-token-gen`). 16 | 17 | Please make sure you use the same Secret to match the token generator. 18 | 19 | ## Client API 20 | 21 | ### Auth header 22 | If you setup an auth secret and generated a token please include it in `Authorization` HTTP header field with `Bearer ` value. For example: 23 | 24 | ``` 25 | Authorization: Bearer xxxxxx.xxxxxxxxxxx.xxxxxxxxxxxxxx 26 | ``` 27 | 28 | ### End points 29 | List keys 30 | > Permission: List 31 | ``` 32 | GET :namespace_identifier/keys 33 | 34 | URL Query Params: 35 | limit, cursor, prefix 36 | ``` 37 | 38 | Get value 39 | > Permission: Get 40 | ``` 41 | GET :namespace_identifier/values/:key_name 42 | 43 | URL Query Params: 44 | cache_ttl 45 | ``` 46 | 47 | Get value metadata 48 | > Permission: GetWithMetaData 49 | ``` 50 | GET :namespace_identifier/values_metadata/:key_name 51 | 52 | URL Query Params: 53 | cache_ttl 54 | ``` 55 | 56 | Put value to key 57 | > Permission: Put 58 | ``` 59 | PUT :namespace_identifier/values/:key_name 60 | 61 | URL Query Params: 62 | expiration, expiration_ttl, metadata 63 | ``` 64 | 65 | Delete key 66 | > Permission: Delete 67 | ``` 68 | DELETE :namespace_identifier/values/:key_name 69 | ``` 70 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(t|j)sx?$': ['@swc/jest'], 4 | }, 5 | testEnvironment: 'node', 6 | setupFiles: ['/polyfill/node-crypto.ts'], 7 | } 8 | 9 | process.env = { 10 | AUTH_SECRET: 'secret', 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-kv-server", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "author": "SerKo (http://github.com/serkodev)", 6 | "description": "", 7 | "keywords": [], 8 | "homepage": "https://github.com/serkodev/cloudflare-kv-server#readme", 9 | "bugs": { 10 | "url": "https://github.com/serkodev/cloudflare-kv-server/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/serkodev/cloudflare-kv-server.git" 15 | }, 16 | "main": "./dist/index.global.js", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "tsup", 22 | "watch": "tsup --watch", 23 | "lint": "eslint .", 24 | "start": "tsx ./src/index.ts", 25 | "dev:local": "tsx watch ./src/index.ts", 26 | "dev": "wrangler dev --config wrangler.dev.toml", 27 | "prepublishOnly": "pnpm build", 28 | "release": "pnpm lint && bump --commit --push --tag && npm publish", 29 | "test": "jest", 30 | "gen:dev": "pnpm -F 'auth-token-gen' dev", 31 | "gen:build": "pnpm -F 'auth-token-gen' build", 32 | "gen:deploy": "pnpm gen:build && git push origin master:deploy" 33 | }, 34 | "packageManager": "pnpm@7.13.6", 35 | "devDependencies": { 36 | "@antfu/eslint-config": "^0.23.1", 37 | "@cloudflare/workers-types": "^3.16.0", 38 | "@swc/core": "^1.3.9", 39 | "@swc/jest": "^0.2.23", 40 | "@types/jest": "^29.2.0", 41 | "@types/lodash": "^4.14.186", 42 | "@types/node": "^17.0.35", 43 | "eslint": "^8.15.0", 44 | "headers-polyfill": "^3.1.2", 45 | "jest": "^29.2.1", 46 | "tsup": "^5.12.8", 47 | "tsx": "^3.3.1", 48 | "typescript": "^4.6.4", 49 | "version-bump-prompt": "^6.1.0", 50 | "wrangler": "^2.20.2" 51 | }, 52 | "dependencies": { 53 | "@tsndr/cloudflare-worker-jwt": "^2.1.2", 54 | "@tsndr/cloudflare-worker-router": "^2.2.0", 55 | "lodash": "^4.17.21" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/auth-token-gen/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/auth-token-gen/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/auth-token-gen/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/auth-token-gen/master.css.js: -------------------------------------------------------------------------------- 1 | export default { 2 | colors: { 3 | 'content': 'black', 4 | 'sub-content': 'gray-60', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/auth-token-gen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-token-gen", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@master/css": "2.0.0-beta.22", 13 | "@master/normal.css": "2.0.0-beta.4", 14 | "css.gg": "^2.0.0", 15 | "vue": "^3.2.37" 16 | }, 17 | "devDependencies": { 18 | "@master/css-compiler": "2.0.0-beta.16", 19 | "@vitejs/plugin-vue": "^3.1.0", 20 | "typescript": "^4.6.4", 21 | "vite": "^3.2.7", 22 | "vue-tsc": "^1.0.9" 23 | } 24 | } -------------------------------------------------------------------------------- /packages/auth-token-gen/src/App.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 96 | 97 | 100 | -------------------------------------------------------------------------------- /packages/auth-token-gen/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/auth-token-gen/src/components/PermissionItem.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 94 | 95 | 98 | -------------------------------------------------------------------------------- /packages/auth-token-gen/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import '@master/normal.css' 3 | import App from './App.vue' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /packages/auth-token-gen/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /packages/auth-token-gen/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "paths": { 16 | "~/*": ["./src/*"], 17 | "@/*": ["../../src/*"] 18 | } 19 | }, 20 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /packages/auth-token-gen/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/auth-token-gen/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import { MasterCSSVitePlugin } from '@master/css-compiler' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | '~/': `${path.resolve(__dirname, 'src')}/`, 11 | '~css.gg/': `${path.resolve(__dirname, 'node_modules/css.gg/icons')}/`, 12 | '@/': `${path.resolve(__dirname, '../../src')}/`, 13 | }, 14 | }, 15 | plugins: [ 16 | MasterCSSVitePlugin(), 17 | vue(), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - packages/* 4 | -------------------------------------------------------------------------------- /polyfill/node-crypto.ts: -------------------------------------------------------------------------------- 1 | import { webcrypto } from 'crypto' 2 | 3 | (global as any).crypto = webcrypto 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '@tsndr/cloudflare-worker-router' 2 | import authMiddleware, { Action, validKeysPrefix } from './middleware/auth' 3 | import namespaceMiddleware from './middleware/namespace' 4 | 5 | const router = new Router() 6 | 7 | router.use(namespaceMiddleware) 8 | 9 | router.get(':namespace_identifier/keys', authMiddleware(Action.List), async ({ req, res }) => { 10 | const { limit, cursor, prefix } = req.query 11 | 12 | // manually check prefix from auth 13 | if (!validKeysPrefix(req)) 14 | throw new Error('invalid key prefix') 15 | 16 | const val = await req.namespace!.list({ 17 | ...(limit !== undefined && { limit: parseInt(limit) }), 18 | cursor, 19 | prefix, 20 | }) 21 | res.body = JSON.stringify(val) 22 | }) 23 | 24 | router.get(':namespace_identifier/values/:key_name', authMiddleware(Action.Get), async ({ req, res }) => { 25 | const { cache_ttl } = req.query 26 | const key = decodeURIComponent(req.params.key_name) 27 | const val = await req.namespace!.get(key, { 28 | ...(cache_ttl !== undefined && { cacheTtl: parseInt(cache_ttl) }), 29 | }) 30 | res.body = JSON.stringify(val) 31 | }) 32 | 33 | // Cloudflare KV API (/metadata/:key_name) returns without value 34 | router.get(':namespace_identifier/values_metadata/:key_name', authMiddleware(Action.Get | Action.GetWithMetaData), async ({ req, res }) => { 35 | const { cache_ttl } = req.query 36 | const key = decodeURIComponent(req.params.key_name) 37 | const val = await req.namespace!.getWithMetadata(key, { 38 | ...(cache_ttl !== undefined && { cacheTtl: parseInt(cache_ttl) }), 39 | }) 40 | res.body = JSON.stringify(val) 41 | }) 42 | 43 | router.put(':namespace_identifier/values/:key_name', authMiddleware(Action.Put), async ({ req, res }) => { 44 | const { expiration, expiration_ttl, metadata } = req.query 45 | const key = decodeURIComponent(req.params.key_name) 46 | const value = req.body 47 | await req.namespace!.put(key, value, { 48 | ...(expiration !== undefined && { expiration: parseInt(expiration) }), 49 | ...(expiration_ttl !== undefined && { expirationTtl: parseInt(expiration_ttl) }), 50 | metadata, 51 | }) 52 | res.body = 'success' 53 | }) 54 | 55 | router.delete(':namespace_identifier/values/:key_name', authMiddleware(Action.Delete), async ({ req, res }) => { 56 | const key = decodeURIComponent(req.params.key_name) 57 | await req.namespace!.delete(key) 58 | res.body = 'success' 59 | }) 60 | 61 | export default { 62 | async fetch(request: Request, env: any): Promise { 63 | return router.handle(env, request) 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/middleware/auth.test.ts: -------------------------------------------------------------------------------- 1 | import type { RouterContext, RouterRequest, RouterResponse } from '@tsndr/cloudflare-worker-router' 2 | import { Headers as HeadersPolyfill } from 'headers-polyfill' 3 | import authMiddleware, { Action, createToken, decodeToken, validKeysPrefix } from './auth' 4 | 5 | const AUTH_SECRET = process.env.AUTH_SECRET || '' 6 | const EXPIRE = Date.now() + 60 * 10 * 1000 7 | 8 | const getContext = (auth: string, namespace_identifier?: string, key_name?: string): RouterContext => { 9 | const req: RouterRequest = { 10 | headers: new HeadersPolyfill({ 11 | authorization: `Bearer ${auth}`, 12 | }) as unknown as Headers, 13 | params: { 14 | ...(namespace_identifier && { namespace_identifier }), 15 | ...(key_name && { key_name }), 16 | }, 17 | url: '', 18 | method: '', 19 | query: {}, 20 | body: undefined, 21 | } 22 | return { 23 | env: process.env, 24 | req, 25 | res: {} as RouterResponse, 26 | next: jest.fn(), 27 | } 28 | } 29 | 30 | test('key', async () => { 31 | const token = await createToken([{ namespaces: 'foo', keys: 'bar', action: Action.All }], EXPIRE, AUTH_SECRET) 32 | { 33 | const ctx = getContext(token, 'foo', 'bar') 34 | await authMiddleware(Action.Get)(ctx) 35 | expect(ctx.next).toHaveBeenCalledTimes(1) 36 | } 37 | { 38 | const ctx = getContext(token, 'not_foo', 'bar') 39 | await expect(authMiddleware(Action.Get)(ctx)).rejects.toThrow() 40 | } 41 | { 42 | const ctx = getContext(token, 'not_foo', 'not_bar') 43 | await expect(authMiddleware(Action.Get)(ctx)).rejects.toThrow() 44 | } 45 | }) 46 | 47 | test('wildcard', async () => { 48 | const token = await createToken([{ namespaces: 'fo*', keys: 'bar', action: Action.All }], EXPIRE, AUTH_SECRET) 49 | { 50 | const ctx = getContext(token, 'foo', 'bar') 51 | await authMiddleware(Action.Get)(ctx) 52 | expect(ctx.next).toHaveBeenCalledTimes(1) 53 | } 54 | { 55 | const ctx = getContext(token, 'for', 'bar') 56 | await authMiddleware(Action.Get)(ctx) 57 | expect(ctx.next).toHaveBeenCalledTimes(1) 58 | } 59 | { 60 | const ctx = getContext(token, 'fo', 'bar') 61 | await authMiddleware(Action.Get)(ctx) 62 | expect(ctx.next).toHaveBeenCalledTimes(1) 63 | } 64 | { 65 | const ctx = getContext(token, 'foo', 'ba') 66 | await expect(authMiddleware(Action.Get)(ctx)).rejects.toThrow() 67 | } 68 | { 69 | const ctx = getContext(token, 'boo', 'bar') 70 | await expect(authMiddleware(Action.Get)(ctx)).rejects.toThrow() 71 | } 72 | }) 73 | 74 | test('wildcard 2', async () => { 75 | const token = await createToken([{ namespaces: '*', keys: '*', action: Action.All }], EXPIRE, AUTH_SECRET) 76 | { 77 | const ctx = getContext(token, 'foo', 'bar') 78 | await authMiddleware(Action.Get)(ctx) 79 | expect(ctx.next).toHaveBeenCalledTimes(1) 80 | } 81 | }) 82 | 83 | test('single action', async () => { 84 | const token = await createToken([{ action: Action.Get }], EXPIRE, AUTH_SECRET) 85 | { 86 | const ctx = getContext(token, 'foo', 'bar') 87 | await authMiddleware(Action.Get)(ctx) 88 | expect(ctx.next).toHaveBeenCalledTimes(1) 89 | } 90 | { 91 | const ctx = getContext(token, 'foo', 'bar') 92 | await authMiddleware(Action.Get & Action.Put)(ctx) 93 | expect(ctx.next).toHaveBeenCalledTimes(1) 94 | } 95 | { 96 | const ctx = getContext(token, 'foo', 'bar') 97 | await expect(authMiddleware(Action.Put)(ctx)).rejects.toThrow() 98 | } 99 | }) 100 | 101 | test('multi actions', async () => { 102 | const token = await createToken([{ action: Action.Get | Action.GetWithMetaData }], EXPIRE, AUTH_SECRET) 103 | { 104 | const ctx = getContext(token, 'foo', 'bar') 105 | await authMiddleware(Action.Get)(ctx) 106 | expect(ctx.next).toHaveBeenCalledTimes(1) 107 | } 108 | { 109 | // must has both Action 110 | const ctx = getContext(token, 'foo', 'bar') 111 | await authMiddleware(Action.Get | Action.GetWithMetaData)(ctx) 112 | expect(ctx.next).toHaveBeenCalledTimes(1) 113 | } 114 | { 115 | // has one of the Action 116 | const ctx = getContext(token, 'foo', 'bar') 117 | await authMiddleware(Action.Get & Action.Put)(ctx) 118 | expect(ctx.next).toHaveBeenCalledTimes(1) 119 | } 120 | { 121 | const ctx = getContext(token, 'foo', 'bar') 122 | await expect(authMiddleware(Action.Put)(ctx)).rejects.toThrow() 123 | } 124 | }) 125 | 126 | test('token expire', async () => { 127 | const token = await createToken([{ namespaces: '*', keys: '*', action: Action.All }], Date.now() - 1, AUTH_SECRET) 128 | { 129 | const ctx = getContext(token, 'foo', 'bar') 130 | await expect(authMiddleware(Action.Get)(ctx)).rejects.toThrow('token expired') 131 | } 132 | }) 133 | 134 | test('token no expire', async () => { 135 | const token = await createToken([{ namespaces: '*', keys: '*', action: Action.All }], undefined, AUTH_SECRET) 136 | { 137 | const ctx = getContext(token, 'foo', 'bar') 138 | await authMiddleware(Action.Get)(ctx) 139 | expect(ctx.next).toHaveBeenCalledTimes(1) 140 | } 141 | }) 142 | 143 | test('validKeysPrefix', () => { 144 | const reqBase = { 145 | url: '', 146 | method: '', 147 | body: undefined, 148 | params: {}, 149 | headers: new HeadersPolyfill() as unknown as Headers, 150 | } 151 | 152 | expect(validKeysPrefix({ 153 | ...reqBase, 154 | permissions: [{ action: Action.List }], 155 | query: { prefix: 'foo' }, 156 | })).toBe(true) 157 | 158 | expect(validKeysPrefix({ 159 | ...reqBase, 160 | permissions: [{ action: Action.List, list_keys_prefix: 'fo*' }], 161 | query: { prefix: 'foo' }, 162 | })).toBe(true) 163 | 164 | expect(validKeysPrefix({ 165 | ...reqBase, 166 | permissions: [{ action: Action.List, list_keys_prefix: '*' }], 167 | query: { prefix: 'foo' }, 168 | })).toBe(true) 169 | 170 | expect(validKeysPrefix({ 171 | ...reqBase, 172 | permissions: [{ action: Action.List, list_keys_prefix: '/.*/' }], 173 | query: { prefix: 'foo' }, 174 | })).toBe(true) 175 | 176 | expect(validKeysPrefix({ 177 | ...reqBase, 178 | permissions: [{ action: Action.List, list_keys_prefix: 'foo' }], 179 | query: { prefix: 'foo' }, 180 | })).toBe(true) 181 | 182 | expect(validKeysPrefix({ 183 | ...reqBase, 184 | permissions: [{ action: Action.List, list_keys_prefix: 'foo' }], 185 | query: { prefix: 'bar' }, 186 | })).toBe(false) 187 | }) 188 | 189 | test('gen token', async () => { 190 | const data = [ 191 | { 192 | namespaces: '*', 193 | keys: '*', 194 | action: Action.All, 195 | }, 196 | ] 197 | const token = await createToken(data, undefined, AUTH_SECRET) 198 | console.log(token) 199 | expect(typeof token).toBe('string') 200 | const decode = await decodeToken(token, AUTH_SECRET) 201 | expect(decode!.data).toStrictEqual(data) 202 | }) 203 | -------------------------------------------------------------------------------- /src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import type { RouterHandler, RouterRequest } from '@tsndr/cloudflare-worker-router' 2 | import escapeRegExp from 'lodash/escapeRegExp' 3 | import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt' 4 | import jwt from '@tsndr/cloudflare-worker-jwt' 5 | 6 | interface AuthTokenPayload extends JwtPayload { 7 | data: Permission[] 8 | } 9 | 10 | declare module '@tsndr/cloudflare-worker-router' { 11 | interface RouterRequest { 12 | permissions?: Permission[] 13 | } 14 | } 15 | 16 | export enum Action { 17 | None = 0, 18 | List = 1 << 0, 19 | Get = 1 << 1, 20 | GetWithMetaData = 1 << 2, 21 | Put = 1 << 3, 22 | Delete = 1 << 4, 23 | All = List | Get | GetWithMetaData | Put | Delete, 24 | } 25 | 26 | export interface Permission { 27 | namespaces?: string 28 | keys?: string 29 | list_keys_prefix?: string 30 | action: Action 31 | } 32 | 33 | const convertRegex = (str: string): RegExp => { 34 | const matches = str.match(/\/(.*)\/(.*)/) 35 | if (matches) { 36 | const [, pattern, flags] = matches 37 | return new RegExp(pattern, flags) 38 | } else { 39 | // https://stackoverflow.com/questions/26246601/wildcard-string-comparison-in-javascript 40 | const pattern = `^${str.split('*').map(escapeRegExp).join('.*')}\$` 41 | return new RegExp(pattern) 42 | } 43 | } 44 | 45 | export const validMatcher = (str: string, pattern?: string) => { 46 | if (pattern === undefined) 47 | return true 48 | 49 | return convertRegex(pattern).test(str) 50 | } 51 | 52 | export const validKeysPrefix = (req: RouterRequest): boolean => { 53 | if (!req.permissions) 54 | return true 55 | const { prefix } = req.query 56 | const permissions = req.permissions!.filter(permission => validMatcher(prefix, permission.list_keys_prefix)) 57 | return permissions.length > 0 58 | } 59 | 60 | // expire: -1 = never 61 | export const createToken = async (permissions: Permission[], expire: number | undefined, secret: string): Promise => { 62 | return await jwt.sign({ 63 | exp: expire, 64 | data: permissions, 65 | }, secret) 66 | } 67 | 68 | export const decodeToken = async (token: string, secret: string): Promise => { 69 | if (await jwt.verify(token, secret)) { 70 | try { 71 | const { payload } = await jwt.decode(token) 72 | if (!payload.data || !Array.isArray(payload.data)) 73 | throw new Error('invalid token payload') 74 | return payload as AuthTokenPayload 75 | } catch (e) {} 76 | } 77 | } 78 | 79 | const getRequestToken = (req: RouterRequest): string | null => { 80 | const authorization = req.headers.get('authorization') 81 | if (authorization) { 82 | const [scheme, token] = authorization.split(' ') 83 | if (scheme === 'Bearer') 84 | return token 85 | } 86 | return null 87 | } 88 | 89 | const authMiddleware = (action: Action): RouterHandler => async ({ req, env, next }) => { 90 | if (!env.AUTH_SECRET) 91 | return await next() 92 | 93 | const authToken = getRequestToken(req) || req.query.auth 94 | if (!authToken) 95 | throw new Error('invalid request') 96 | 97 | const payload = await decodeToken(authToken, env.AUTH_SECRET) 98 | if (payload === undefined) 99 | throw new Error('invalid token') 100 | 101 | if (payload.exp !== undefined && payload.exp < Date.now()) 102 | throw new Error('token expired') 103 | 104 | let permissions: Permission[] = payload.data 105 | 106 | permissions = permissions.filter(permission => (permission.action & action) === action) 107 | 108 | if (req.params.namespace_identifier) 109 | permissions = permissions.filter(permission => validMatcher(req.params.namespace_identifier, permission.namespaces)) 110 | 111 | if (req.params.key_name) 112 | permissions = permissions.filter(permission => validMatcher(req.params.key_name, permission.keys)) 113 | 114 | if (permissions.length === 0) 115 | throw new Error('token no permission') 116 | 117 | req.permissions = permissions 118 | await next() 119 | } 120 | 121 | export default authMiddleware 122 | -------------------------------------------------------------------------------- /src/middleware/namespace.ts: -------------------------------------------------------------------------------- 1 | import type { RouterHandler } from '@tsndr/cloudflare-worker-router' 2 | 3 | declare module '@tsndr/cloudflare-worker-router' { 4 | interface RouterRequest { 5 | namespace?: KVNamespace 6 | } 7 | } 8 | 9 | const namespaceMiddleware: RouterHandler = ({ req, env, next }) => { 10 | if (req.params.namespace_identifier) { 11 | const namespaceIdentifier = decodeURIComponent(req.params.namespace_identifier) 12 | const namespace = env[namespaceIdentifier] 13 | if (!namespace || typeof namespace !== 'object' || !namespace.get) 14 | throw new Error('invalid namespace') 15 | req.namespace = env[namespaceIdentifier] 16 | } 17 | return next() 18 | } 19 | 20 | export default namespaceMiddleware 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | "lib": ["esnext"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "skipDefaultLibCheck": true, 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | entry: ['src/index.ts'], 6 | format: ['iife'], 7 | }) 8 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cloudflare-kv-server" 2 | main = "src/index.ts" 3 | compatibility_date = "2022-10-11" 4 | 5 | # kv_namespaces = [ 6 | # { binding = "your_key_bind", id = "kv_namespace_id" } 7 | # ] 8 | 9 | # [vars] 10 | # AUTH_SECRET = "your_secret" 11 | --------------------------------------------------------------------------------