@zintrust/cloudflare-d1-proxy 0.7.9
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.
- package/README.md +108 -0
- package/dist/SignedRequest.d.ts +38 -0
- package/dist/SignedRequest.js +144 -0
- package/dist/build-manifest.json +45 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +398 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @zintrust/cloudflare-d1-proxy
|
|
2
|
+
|
|
3
|
+
Cloudflare Worker service that exposes a small HTTPS API for executing D1 operations.
|
|
4
|
+
|
|
5
|
+
Docs: https://zintrust.com/package-cloudflare-d1-proxy
|
|
6
|
+
|
|
7
|
+
This is intended for **server-to-server** use (e.g. a Node app running outside Cloudflare), via ZinTrust’s `d1-remote` adapter.
|
|
8
|
+
|
|
9
|
+
## Endpoints
|
|
10
|
+
|
|
11
|
+
All endpoints are `POST` and require signed request headers.
|
|
12
|
+
|
|
13
|
+
- `/zin/d1/query` → `{ sql, params }` → `{ rows, rowCount }`
|
|
14
|
+
- `/zin/d1/queryOne` → `{ sql, params }` → `{ row }`
|
|
15
|
+
- `/zin/d1/exec` → `{ sql, params }` → `{ ok: true, meta? }`
|
|
16
|
+
- `/zin/d1/statement` → `{ statementId, params }` → `{ rows, rowCount }` or `{ ok: true, meta? }`
|
|
17
|
+
|
|
18
|
+
## Required bindings
|
|
19
|
+
|
|
20
|
+
- D1 binding: `DB`
|
|
21
|
+
|
|
22
|
+
If your binding name is not `DB`, set Worker var `D1_BINDING` to your binding name.
|
|
23
|
+
|
|
24
|
+
Optional (recommended):
|
|
25
|
+
|
|
26
|
+
- KV binding: `ZT_NONCES` (nonce replay protection)
|
|
27
|
+
|
|
28
|
+
## Required secrets / vars
|
|
29
|
+
|
|
30
|
+
**Secret (required):**
|
|
31
|
+
|
|
32
|
+
- `D1_REMOTE_SECRET` – shared signing secret used to verify requests.
|
|
33
|
+
- `APP_KEY` – fallback shared signing secret if `D1_REMOTE_SECRET` is not set.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"k1": { "secret": "super-secret-shared-key" }
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Vars (optional):**
|
|
44
|
+
|
|
45
|
+
- `ZT_PROXY_SIGNING_WINDOW_MS` (default `60000`)
|
|
46
|
+
- `ZT_MAX_BODY_BYTES` (default `131072`)
|
|
47
|
+
- `ZT_MAX_SQL_BYTES` (default `32768`)
|
|
48
|
+
- `ZT_MAX_PARAMS` (default `256`)
|
|
49
|
+
|
|
50
|
+
**Secret/var (optional):**
|
|
51
|
+
|
|
52
|
+
- `ZT_D1_STATEMENTS_JSON` – required if you use `/zin/d1/statement` (registry mode). This is a JSON map of `statementId -> sql`.
|
|
53
|
+
|
|
54
|
+
## Deploy
|
|
55
|
+
|
|
56
|
+
From this package directory:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
wrangler deploy
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Set secrets:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
wrangler secret put D1_REMOTE_SECRET
|
|
66
|
+
# optional
|
|
67
|
+
wrangler secret put ZT_D1_STATEMENTS_JSON
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Use from ZinTrust (Node app)
|
|
71
|
+
|
|
72
|
+
Configure your app:
|
|
73
|
+
|
|
74
|
+
- `DB_CONNECTION=d1-remote`
|
|
75
|
+
- `D1_REMOTE_URL=https://<your-worker-host>`
|
|
76
|
+
- `D1_REMOTE_KEY_ID=k1`
|
|
77
|
+
- `D1_REMOTE_SECRET=super-secret-shared-key`
|
|
78
|
+
- `D1_REMOTE_MODE=registry` or `sql`
|
|
79
|
+
|
|
80
|
+
Then use `Database` / QueryBuilder as normal.
|
|
81
|
+
|
|
82
|
+
## Threat model (what this protects)
|
|
83
|
+
|
|
84
|
+
Registry mode (`/zin/d1/statement` + `ZT_D1_STATEMENTS_JSON`) primarily reduces risk when there is a **trust boundary** (your app calls this Worker proxy over HTTPS).
|
|
85
|
+
|
|
86
|
+
In registry mode, the caller sends only `{ statementId, params }` and the Worker looks up SQL from the allowlist. This prevents **network-level** SQL injection into the proxy (the proxy never receives SQL text to be injected).
|
|
87
|
+
|
|
88
|
+
### What registry mode does NOT automatically prevent
|
|
89
|
+
|
|
90
|
+
- Authorization bugs (e.g. querying another user’s data by changing `id` parameters).
|
|
91
|
+
- Dangerous allowlisted statements (wide `UPDATE`/`DELETE`, admin operations).
|
|
92
|
+
- Replay/duplicate execution (must be prevented via nonce + timestamp verification).
|
|
93
|
+
- DoS / expensive queries (needs rate limiting, timeouts, and query budgets).
|
|
94
|
+
- A fully compromised app runtime (RCE) — attackers can steal secrets and abuse whatever is allowed.
|
|
95
|
+
|
|
96
|
+
### Threat model table
|
|
97
|
+
|
|
98
|
+
| Attacker scenario (facts) | What can go wrong | What helps most | What registry mode helps with | What registry mode does NOT fix |
|
|
99
|
+
| ----------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
100
|
+
| Proxy signing secret leaked (CI logs, env leak, SSRF reading env, etc.) | Attacker can call proxy endpoints as a trusted client | Request signing + nonce/timestamp replay protection + rate limiting | **Big win**: attacker limited to allowlisted statements (no arbitrary SQL) | If allowlist includes dangerous statements, attacker can still cause damage |
|
|
101
|
+
| App SQL injection bug (string concatenation, unsafe interpolation) | Arbitrary SQL may run using app’s DB credentials | Parameterized queries + query builders + linting + tests | Limited value for direct DB; can become reliability failure (statementId won’t match) | Does not fix SQLi root cause; attacker may still exfiltrate/modify via app |
|
|
102
|
+
| App runtime compromised (RCE) | Secrets stolen, arbitrary internal calls, data theft | Least-privilege credentials, network segmentation, secret rotation, monitoring | Some value if proxy creds leak is the only path and allowlist is tight | If attacker has code exec, they can still abuse allowed reads/writes |
|
|
103
|
+
| Replay / MITM within allowed clock skew | Re-sending signed requests can repeat effects | Nonce + timestamp verification; short signing window | Minor (statements still re-playable) | Registry does not prevent replay; must be blocked by nonce/time |
|
|
104
|
+
| DoS / resource exhaustion | High CPU/DB load, high egress, timeouts | Rate limiting, payload limits, query timeouts, concurrency limits | Minor (allowlisted queries can still be expensive) | Registry doesn’t limit cost by itself |
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
This package and its dependencies are MIT licensed, permitting free commercial use.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type SignedRequestHeaders = {
|
|
2
|
+
'x-zt-key-id': string;
|
|
3
|
+
'x-zt-timestamp': string;
|
|
4
|
+
'x-zt-nonce': string;
|
|
5
|
+
'x-zt-body-sha256': string;
|
|
6
|
+
'x-zt-signature': string;
|
|
7
|
+
};
|
|
8
|
+
export type SignedRequestVerifyParams = {
|
|
9
|
+
method: string;
|
|
10
|
+
url: string | URL;
|
|
11
|
+
body?: string | Uint8Array | null;
|
|
12
|
+
headers: Headers | Record<string, string | undefined>;
|
|
13
|
+
getSecretForKeyId: (keyId: string) => string | undefined | Promise<string | undefined>;
|
|
14
|
+
nowMs?: number;
|
|
15
|
+
windowMs?: number;
|
|
16
|
+
verifyNonce?: (keyId: string, nonce: string, ttlMs: number) => Promise<boolean>;
|
|
17
|
+
};
|
|
18
|
+
export type SignedRequestVerifyResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
keyId: string;
|
|
21
|
+
timestampMs: number;
|
|
22
|
+
nonce: string;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
code: 'MISSING_HEADER' | 'INVALID_TIMESTAMP' | 'EXPIRED' | 'INVALID_BODY_SHA' | 'INVALID_SIGNATURE' | 'UNKNOWN_KEY' | 'REPLAYED';
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
export declare const SignedRequest: Readonly<{
|
|
29
|
+
sha256Hex: (data: string | Uint8Array) => Promise<string>;
|
|
30
|
+
canonicalString: (params: {
|
|
31
|
+
method: string;
|
|
32
|
+
url: string | URL;
|
|
33
|
+
timestampMs: number;
|
|
34
|
+
nonce: string;
|
|
35
|
+
bodySha256Hex: string;
|
|
36
|
+
}) => string;
|
|
37
|
+
verify(params: SignedRequestVerifyParams): Promise<SignedRequestVerifyResult>;
|
|
38
|
+
}>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const getHeader = (headers, name) => {
|
|
2
|
+
if (typeof headers.get === 'function') {
|
|
3
|
+
const value = headers.get(name);
|
|
4
|
+
return value ?? undefined;
|
|
5
|
+
}
|
|
6
|
+
return headers[name];
|
|
7
|
+
};
|
|
8
|
+
const timingSafeEquals = (a, b) => {
|
|
9
|
+
if (a.length !== b.length)
|
|
10
|
+
return false;
|
|
11
|
+
let result = 0;
|
|
12
|
+
for (let i = 0; i < a.length; i++) {
|
|
13
|
+
result |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0);
|
|
14
|
+
}
|
|
15
|
+
return result === 0;
|
|
16
|
+
};
|
|
17
|
+
const getSubtleOrNull = () => {
|
|
18
|
+
if (typeof crypto === 'undefined' || crypto.subtle === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
return crypto.subtle;
|
|
21
|
+
};
|
|
22
|
+
const toBytes = (data) => {
|
|
23
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
24
|
+
return new Uint8Array(bytes);
|
|
25
|
+
};
|
|
26
|
+
const toHex = (bytes) => {
|
|
27
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
28
|
+
let out = '';
|
|
29
|
+
for (const element of view) {
|
|
30
|
+
out += element.toString(16).padStart(2, '0');
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
const sha256Hex = async (data) => {
|
|
35
|
+
const subtle = getSubtleOrNull();
|
|
36
|
+
if (subtle === null)
|
|
37
|
+
return '';
|
|
38
|
+
const digest = await subtle.digest('SHA-256', toBytes(data));
|
|
39
|
+
return toHex(digest);
|
|
40
|
+
};
|
|
41
|
+
const canonicalString = (params) => {
|
|
42
|
+
const u = typeof params.url === 'string' ? new URL(params.url) : params.url;
|
|
43
|
+
const method = params.method.toUpperCase();
|
|
44
|
+
return [
|
|
45
|
+
method,
|
|
46
|
+
u.pathname,
|
|
47
|
+
u.search,
|
|
48
|
+
String(params.timestampMs),
|
|
49
|
+
params.nonce,
|
|
50
|
+
params.bodySha256Hex,
|
|
51
|
+
].join('\n');
|
|
52
|
+
};
|
|
53
|
+
const parseAndValidateHeaders = (headers) => {
|
|
54
|
+
const keyId = getHeader(headers, 'x-zt-key-id');
|
|
55
|
+
const ts = getHeader(headers, 'x-zt-timestamp');
|
|
56
|
+
const nonce = getHeader(headers, 'x-zt-nonce');
|
|
57
|
+
const bodySha = getHeader(headers, 'x-zt-body-sha256');
|
|
58
|
+
const signature = getHeader(headers, 'x-zt-signature');
|
|
59
|
+
if (keyId === undefined ||
|
|
60
|
+
ts === undefined ||
|
|
61
|
+
nonce === undefined ||
|
|
62
|
+
bodySha === undefined ||
|
|
63
|
+
signature === undefined) {
|
|
64
|
+
return { ok: false, code: 'MISSING_HEADER', message: 'Missing required signing headers' };
|
|
65
|
+
}
|
|
66
|
+
const timestampMs = Number.parseInt(ts, 10);
|
|
67
|
+
if (!Number.isFinite(timestampMs)) {
|
|
68
|
+
return { ok: false, code: 'INVALID_TIMESTAMP', message: 'Invalid x-zt-timestamp' };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, keyId, timestampMs, nonce, bodySha, signature };
|
|
71
|
+
};
|
|
72
|
+
const validateTimestampWindow = (params) => {
|
|
73
|
+
if (Math.abs(params.nowMs - params.timestampMs) > params.windowMs) {
|
|
74
|
+
return { ok: false, code: 'EXPIRED', message: 'Request timestamp outside allowed window' };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
};
|
|
78
|
+
const validateBodyHash = async (params) => {
|
|
79
|
+
const computedBodySha = await sha256Hex(params.body);
|
|
80
|
+
if (computedBodySha === '' || !timingSafeEquals(computedBodySha, params.bodyShaHeader)) {
|
|
81
|
+
return { ok: false, code: 'INVALID_BODY_SHA', message: 'Body hash mismatch' };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true };
|
|
84
|
+
};
|
|
85
|
+
const validateSignature = async (params) => {
|
|
86
|
+
const subtle = getSubtleOrNull();
|
|
87
|
+
if (subtle === null) {
|
|
88
|
+
return { ok: false, code: 'INVALID_SIGNATURE', message: 'WebCrypto is not available' };
|
|
89
|
+
}
|
|
90
|
+
const canonical = canonicalString({
|
|
91
|
+
method: params.method,
|
|
92
|
+
url: params.url,
|
|
93
|
+
timestampMs: params.timestampMs,
|
|
94
|
+
nonce: params.nonce,
|
|
95
|
+
bodySha256Hex: params.bodySha,
|
|
96
|
+
});
|
|
97
|
+
const key = await subtle.importKey('raw', toBytes(params.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
98
|
+
const expectedBytes = await subtle.sign('HMAC', key, toBytes(canonical));
|
|
99
|
+
const expected = toHex(expectedBytes);
|
|
100
|
+
if (!timingSafeEquals(expected, params.signature)) {
|
|
101
|
+
return { ok: false, code: 'INVALID_SIGNATURE', message: 'Invalid signature' };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
};
|
|
105
|
+
export const SignedRequest = Object.freeze({
|
|
106
|
+
sha256Hex,
|
|
107
|
+
canonicalString,
|
|
108
|
+
async verify(params) {
|
|
109
|
+
const parsed = parseAndValidateHeaders(params.headers);
|
|
110
|
+
if (parsed.ok === false)
|
|
111
|
+
return parsed;
|
|
112
|
+
const { keyId, timestampMs, nonce, bodySha, signature } = parsed;
|
|
113
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
114
|
+
const windowMs = params.windowMs ?? 60_000;
|
|
115
|
+
const windowCheck = validateTimestampWindow({ nowMs, timestampMs, windowMs });
|
|
116
|
+
if (windowCheck.ok === false)
|
|
117
|
+
return windowCheck;
|
|
118
|
+
const bodyCheck = await validateBodyHash({ body: params.body ?? '', bodyShaHeader: bodySha });
|
|
119
|
+
if (bodyCheck.ok === false)
|
|
120
|
+
return bodyCheck;
|
|
121
|
+
const secret = await params.getSecretForKeyId(keyId);
|
|
122
|
+
if (secret === undefined || secret.trim() === '') {
|
|
123
|
+
return { ok: false, code: 'UNKNOWN_KEY', message: 'Unknown key id' };
|
|
124
|
+
}
|
|
125
|
+
const sigCheck = await validateSignature({
|
|
126
|
+
method: params.method,
|
|
127
|
+
url: params.url,
|
|
128
|
+
timestampMs,
|
|
129
|
+
nonce,
|
|
130
|
+
bodySha,
|
|
131
|
+
signature,
|
|
132
|
+
secret,
|
|
133
|
+
});
|
|
134
|
+
if (sigCheck.ok === false)
|
|
135
|
+
return sigCheck;
|
|
136
|
+
if (params.verifyNonce !== undefined) {
|
|
137
|
+
const ok = await params.verifyNonce(keyId, nonce, windowMs);
|
|
138
|
+
if (ok === false) {
|
|
139
|
+
return { ok: false, code: 'REPLAYED', message: 'Nonce replayed or rejected' };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, keyId, timestampMs, nonce };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zintrust/cloudflare-d1-proxy",
|
|
3
|
+
"version": "0.7.9",
|
|
4
|
+
"buildDate": "2026-04-20T20:59:39.877Z",
|
|
5
|
+
"buildEnvironment": {
|
|
6
|
+
"node": "v22.22.1",
|
|
7
|
+
"platform": "darwin",
|
|
8
|
+
"arch": "arm64"
|
|
9
|
+
},
|
|
10
|
+
"git": {
|
|
11
|
+
"commit": "e04d1cae",
|
|
12
|
+
"branch": "release"
|
|
13
|
+
},
|
|
14
|
+
"package": {
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": [],
|
|
19
|
+
"peerDependencies": [
|
|
20
|
+
"@zintrust/core"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"files": {
|
|
24
|
+
"SignedRequest.d.ts": {
|
|
25
|
+
"size": 1270,
|
|
26
|
+
"sha256": "961adc3b27574e480dfaa478b20e5fae421f59500b3fcdf2e10414550a963f6f"
|
|
27
|
+
},
|
|
28
|
+
"SignedRequest.js": {
|
|
29
|
+
"size": 5398,
|
|
30
|
+
"sha256": "5be1bc4b1ad88b1980c28e5ca45c1e564f44466bd5d0cda8bc07403031ce87a1"
|
|
31
|
+
},
|
|
32
|
+
"build-manifest.json": {
|
|
33
|
+
"size": 1112,
|
|
34
|
+
"sha256": "7ab95afeeaab1ef9f3e3b9c01f6d63ab79302145c4b739ab69138fc7bb1299ec"
|
|
35
|
+
},
|
|
36
|
+
"index.d.ts": {
|
|
37
|
+
"size": 1510,
|
|
38
|
+
"sha256": "d67887da027057cab5a12e732cacd3b8c014c9ff5067734d82d946304c5f8cf5"
|
|
39
|
+
},
|
|
40
|
+
"index.js": {
|
|
41
|
+
"size": 14083,
|
|
42
|
+
"sha256": "68851c99815f0fb331c75eae2f90b5f057eb84ca719211812378e9f837905316"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
type KvGetType = 'text' | 'json' | 'arrayBuffer';
|
|
2
|
+
type KVNamespacePutOptions = {
|
|
3
|
+
expirationTtl?: number;
|
|
4
|
+
};
|
|
5
|
+
type KVNamespace = {
|
|
6
|
+
get: {
|
|
7
|
+
(key: string): Promise<string | null>;
|
|
8
|
+
(key: string, type: 'json'): Promise<unknown | null>;
|
|
9
|
+
(key: string, type: 'arrayBuffer'): Promise<ArrayBuffer | null>;
|
|
10
|
+
(key: string, type: KvGetType): Promise<unknown | ArrayBuffer | string | null>;
|
|
11
|
+
};
|
|
12
|
+
put: (key: string, value: string, options?: KVNamespacePutOptions) => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
type D1AllResult<T> = {
|
|
15
|
+
results?: T[];
|
|
16
|
+
};
|
|
17
|
+
type D1RunResult = {
|
|
18
|
+
meta?: unknown;
|
|
19
|
+
};
|
|
20
|
+
type D1PreparedStatement = {
|
|
21
|
+
bind: (...values: unknown[]) => D1PreparedStatement;
|
|
22
|
+
all: <T = unknown>() => Promise<D1AllResult<T>>;
|
|
23
|
+
first: <T = unknown>() => Promise<T | null>;
|
|
24
|
+
run: () => Promise<D1RunResult>;
|
|
25
|
+
};
|
|
26
|
+
type D1Database = {
|
|
27
|
+
prepare: (sql: string) => D1PreparedStatement;
|
|
28
|
+
};
|
|
29
|
+
type D1Env = {
|
|
30
|
+
DB?: D1Database;
|
|
31
|
+
D1_BINDING?: string;
|
|
32
|
+
APP_KEY?: string;
|
|
33
|
+
D1_REMOTE_SECRET?: string;
|
|
34
|
+
ZT_PROXY_SIGNING_WINDOW_MS?: string;
|
|
35
|
+
ZT_NONCES?: KVNamespace;
|
|
36
|
+
ZT_PROXY_DEBUG?: string;
|
|
37
|
+
ZT_MAX_BODY_BYTES?: string;
|
|
38
|
+
ZT_MAX_SQL_BYTES?: string;
|
|
39
|
+
ZT_MAX_PARAMS?: string;
|
|
40
|
+
ZT_D1_STATEMENTS_JSON?: string;
|
|
41
|
+
};
|
|
42
|
+
export declare const ZintrustD1Proxy: Readonly<{
|
|
43
|
+
_ZINTRUST_CLOUDFLARE_D1_PROXY_VERSION: "0.1.15";
|
|
44
|
+
_ZINTRUST_CLOUDFLARE_D1_PROXY_BUILD_DATE: "__BUILD_DATE__";
|
|
45
|
+
fetch(request: Request, env: D1Env): Promise<Response>;
|
|
46
|
+
}>;
|
|
47
|
+
export default ZintrustD1Proxy;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zintrust/cloudflare-d1-proxy v0.7.9
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare D1 proxy package for ZinTrust.
|
|
5
|
+
*
|
|
6
|
+
* Build Information:
|
|
7
|
+
* Built: 2026-04-20T20:59:39.807Z
|
|
8
|
+
* Node: >=20.0.0
|
|
9
|
+
* License: MIT
|
|
10
|
+
*
|
|
11
|
+
*/
|
|
12
|
+
import { ErrorHandler, RequestValidator, SigningService } from '@zintrust/core/proxy';
|
|
13
|
+
const DEFAULT_SIGNING_WINDOW_MS = 60_000;
|
|
14
|
+
const DEFAULT_MAX_BODY_BYTES = 128 * 1024;
|
|
15
|
+
const DEFAULT_MAX_SQL_BYTES = 32 * 1024;
|
|
16
|
+
const DEFAULT_MAX_PARAMS = 256;
|
|
17
|
+
const json = (status, body) => new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
21
|
+
'Cache-Control': 'no-store',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const toErrorResponse = (status, code, message) => {
|
|
25
|
+
const error = ErrorHandler.toProxyError(status, code, message);
|
|
26
|
+
return json(error.status, error.body);
|
|
27
|
+
};
|
|
28
|
+
const getEnvInt = (env, name, fallback) => {
|
|
29
|
+
const raw = env[name];
|
|
30
|
+
if (typeof raw !== 'string')
|
|
31
|
+
return fallback;
|
|
32
|
+
const parsed = Number.parseInt(raw, 10);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
34
|
+
};
|
|
35
|
+
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
36
|
+
const isString = (value) => typeof value === 'string';
|
|
37
|
+
const isArray = (value) => Array.isArray(value);
|
|
38
|
+
const isDebugEnabled = (env) => {
|
|
39
|
+
const raw = env.ZT_PROXY_DEBUG;
|
|
40
|
+
if (typeof raw !== 'string')
|
|
41
|
+
return false;
|
|
42
|
+
const normalized = raw.trim().toLowerCase();
|
|
43
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
|
44
|
+
};
|
|
45
|
+
const safeErrorMessage = (error) => {
|
|
46
|
+
if (error instanceof Error)
|
|
47
|
+
return error.message;
|
|
48
|
+
if (typeof error === 'string')
|
|
49
|
+
return error;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(error);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return 'Unknown D1 error';
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const logProxyError = (env, context, error) => {
|
|
58
|
+
if (!isDebugEnabled(env))
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.error('[ZintrustD1Proxy] error', {
|
|
63
|
+
...context,
|
|
64
|
+
message: safeErrorMessage(error).slice(0, 800),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// ignore logging failures
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const normalizeBindingName = (value) => {
|
|
72
|
+
if (typeof value !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
return trimmed === '' ? null : trimmed;
|
|
76
|
+
};
|
|
77
|
+
const resolveD1Binding = (env) => {
|
|
78
|
+
const candidates = ['DB', 'zintrust_db', normalizeBindingName(env.D1_BINDING)].filter((v, idx, arr) => typeof v === 'string' && v.trim() !== '' && arr.indexOf(v) === idx);
|
|
79
|
+
const record = env;
|
|
80
|
+
for (const name of candidates) {
|
|
81
|
+
const binding = record[name];
|
|
82
|
+
if (binding !== undefined && binding !== null && typeof binding.prepare === 'function')
|
|
83
|
+
return binding;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
const readBodyBytes = async (request, maxBytes) => {
|
|
88
|
+
const buf = await request.arrayBuffer();
|
|
89
|
+
if (buf.byteLength > maxBytes) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
response: toErrorResponse(413, 'PAYLOAD_TOO_LARGE', 'Body too large'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const bytes = new Uint8Array(buf);
|
|
96
|
+
const text = new TextDecoder().decode(bytes);
|
|
97
|
+
return { ok: true, bytes, text };
|
|
98
|
+
};
|
|
99
|
+
const parseOptionalJson = (text) => {
|
|
100
|
+
if (text.trim() === '')
|
|
101
|
+
return { ok: true, payload: null };
|
|
102
|
+
const parsed = RequestValidator.parseJson(text);
|
|
103
|
+
if (!parsed.ok) {
|
|
104
|
+
// Type guard: we know parsed has 'error' property when ok is false
|
|
105
|
+
const errorResult = parsed;
|
|
106
|
+
let message = errorResult.error.message;
|
|
107
|
+
if (errorResult.error.code === 'INVALID_JSON') {
|
|
108
|
+
message = 'Invalid JSON body';
|
|
109
|
+
}
|
|
110
|
+
else if (errorResult.error.code === 'VALIDATION_ERROR') {
|
|
111
|
+
message = 'Invalid body';
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, response: toErrorResponse(400, errorResult.error.code, message) };
|
|
114
|
+
}
|
|
115
|
+
// Type guard: we know parsed has 'value' property when ok is true
|
|
116
|
+
const successResult = parsed;
|
|
117
|
+
return { ok: true, payload: successResult.value };
|
|
118
|
+
};
|
|
119
|
+
const loadSigningSecret = (env) => {
|
|
120
|
+
const direct = typeof env.D1_REMOTE_SECRET === 'string' ? env.D1_REMOTE_SECRET.trim() : '';
|
|
121
|
+
if (direct !== '')
|
|
122
|
+
return direct;
|
|
123
|
+
const fallback = typeof env.APP_KEY === 'string' ? env.APP_KEY.trim() : '';
|
|
124
|
+
if (fallback !== '')
|
|
125
|
+
return fallback;
|
|
126
|
+
return null;
|
|
127
|
+
};
|
|
128
|
+
const loadStatements = (env) => {
|
|
129
|
+
const raw = env.ZT_D1_STATEMENTS_JSON;
|
|
130
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(raw);
|
|
134
|
+
if (!isRecord(parsed))
|
|
135
|
+
return null;
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const isMutatingSql = (sql) => {
|
|
143
|
+
const s = sql.trimStart().toLowerCase();
|
|
144
|
+
return (s.startsWith('insert') ||
|
|
145
|
+
s.startsWith('update') ||
|
|
146
|
+
s.startsWith('delete') ||
|
|
147
|
+
s.startsWith('create') ||
|
|
148
|
+
s.startsWith('drop') ||
|
|
149
|
+
s.startsWith('alter') ||
|
|
150
|
+
s.startsWith('replace'));
|
|
151
|
+
};
|
|
152
|
+
const verifyNonceKv = async (kv, keyId, nonce, ttlMs) => {
|
|
153
|
+
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
|
|
154
|
+
const storageKey = `nonce:${keyId}:${nonce}`;
|
|
155
|
+
const existing = await kv.get(storageKey);
|
|
156
|
+
if (existing !== null)
|
|
157
|
+
return false;
|
|
158
|
+
await kv.put(storageKey, '1', { expirationTtl: ttlSeconds });
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
const verifySignedRequest = async (request, env, bodyBytes) => {
|
|
162
|
+
const secret = loadSigningSecret(env);
|
|
163
|
+
if (secret === null) {
|
|
164
|
+
return toErrorResponse(401, 'CONFIG_ERROR', 'Missing signing secret (D1_REMOTE_SECRET or APP_KEY)');
|
|
165
|
+
}
|
|
166
|
+
const windowMs = getEnvInt(env, 'ZT_PROXY_SIGNING_WINDOW_MS', DEFAULT_SIGNING_WINDOW_MS);
|
|
167
|
+
const verifyResult = await SigningService.verifyWithKeyProvider({
|
|
168
|
+
method: request.method,
|
|
169
|
+
url: request.url,
|
|
170
|
+
body: bodyBytes,
|
|
171
|
+
headers: request.headers,
|
|
172
|
+
windowMs,
|
|
173
|
+
getSecretForKeyId: async (_keyId) => secret,
|
|
174
|
+
verifyNonce: env.ZT_NONCES === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: async (keyId, nonce, ttlMs) => verifyNonceKv(env.ZT_NONCES, keyId, nonce, ttlMs),
|
|
177
|
+
});
|
|
178
|
+
if (verifyResult.ok === false) {
|
|
179
|
+
return toErrorResponse(verifyResult.status, verifyResult.code, verifyResult.message);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true };
|
|
182
|
+
};
|
|
183
|
+
const requireDb = (env) => {
|
|
184
|
+
const db = resolveD1Binding(env);
|
|
185
|
+
if (db === null) {
|
|
186
|
+
return toErrorResponse(400, 'CONFIG_ERROR', 'Missing D1 binding (DB) or binding name via D1_BINDING');
|
|
187
|
+
}
|
|
188
|
+
return db;
|
|
189
|
+
};
|
|
190
|
+
const toD1ExceptionResponse = (error) => {
|
|
191
|
+
let message;
|
|
192
|
+
if (error instanceof Error) {
|
|
193
|
+
message = error.message;
|
|
194
|
+
}
|
|
195
|
+
else if (typeof error === 'string') {
|
|
196
|
+
message = error;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
message = 'Unknown D1 error';
|
|
200
|
+
}
|
|
201
|
+
return toErrorResponse(500, 'D1_ERROR', message);
|
|
202
|
+
};
|
|
203
|
+
const parseSqlPayload = (payload) => {
|
|
204
|
+
if (!isRecord(payload)) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
response: toErrorResponse(400, 'VALIDATION_ERROR', 'Invalid body'),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const sql = payload['sql'];
|
|
211
|
+
const params = payload['params'];
|
|
212
|
+
if (!isString(sql)) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
response: toErrorResponse(400, 'VALIDATION_ERROR', 'sql must be a string'),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, sql, params: isArray(params) ? params : [] };
|
|
219
|
+
};
|
|
220
|
+
const enforceSqlLimits = (env, sql, params) => {
|
|
221
|
+
const maxSqlBytes = getEnvInt(env, 'ZT_MAX_SQL_BYTES', DEFAULT_MAX_SQL_BYTES);
|
|
222
|
+
const maxParams = getEnvInt(env, 'ZT_MAX_PARAMS', DEFAULT_MAX_PARAMS);
|
|
223
|
+
if (new TextEncoder().encode(sql).byteLength > maxSqlBytes) {
|
|
224
|
+
return toErrorResponse(413, 'PAYLOAD_TOO_LARGE', 'SQL too large');
|
|
225
|
+
}
|
|
226
|
+
if (params.length > maxParams) {
|
|
227
|
+
return toErrorResponse(400, 'VALIDATION_ERROR', 'Too many params');
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
};
|
|
231
|
+
const readAndVerifyJson = async (request, env) => {
|
|
232
|
+
const maxBodyBytes = getEnvInt(env, 'ZT_MAX_BODY_BYTES', DEFAULT_MAX_BODY_BYTES);
|
|
233
|
+
const bodyResult = await readBodyBytes(request, maxBodyBytes);
|
|
234
|
+
if (bodyResult.ok === false)
|
|
235
|
+
return { ok: false, response: bodyResult.response };
|
|
236
|
+
const auth = await verifySignedRequest(request, env, bodyResult.bytes);
|
|
237
|
+
if (auth instanceof Response)
|
|
238
|
+
return { ok: false, response: auth };
|
|
239
|
+
const parsed = parseOptionalJson(bodyResult.text);
|
|
240
|
+
if (parsed.ok === false)
|
|
241
|
+
return { ok: false, response: parsed.response };
|
|
242
|
+
return { ok: true, payload: parsed.payload, bodyBytes: bodyResult.bytes };
|
|
243
|
+
};
|
|
244
|
+
const handleQuery = async (request, env) => {
|
|
245
|
+
try {
|
|
246
|
+
const check = await readAndVerifyJson(request, env);
|
|
247
|
+
if (check.ok === false)
|
|
248
|
+
return check.response;
|
|
249
|
+
const db = requireDb(env);
|
|
250
|
+
if (db instanceof Response)
|
|
251
|
+
return db;
|
|
252
|
+
const parsed = parseSqlPayload(check.payload);
|
|
253
|
+
if (parsed.ok === false)
|
|
254
|
+
return parsed.response;
|
|
255
|
+
const limit = enforceSqlLimits(env, parsed.sql, parsed.params);
|
|
256
|
+
if (limit !== null)
|
|
257
|
+
return limit;
|
|
258
|
+
const result = await db
|
|
259
|
+
.prepare(parsed.sql)
|
|
260
|
+
.bind(...parsed.params)
|
|
261
|
+
.all();
|
|
262
|
+
const rows = result.results ?? [];
|
|
263
|
+
return json(200, { rows, rowCount: rows.length });
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
logProxyError(env, { op: 'query', path: '/zin/d1/query' }, error);
|
|
267
|
+
return toD1ExceptionResponse(error);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const handleQueryOne = async (request, env) => {
|
|
271
|
+
try {
|
|
272
|
+
const check = await readAndVerifyJson(request, env);
|
|
273
|
+
if (check.ok === false)
|
|
274
|
+
return check.response;
|
|
275
|
+
const db = requireDb(env);
|
|
276
|
+
if (db instanceof Response)
|
|
277
|
+
return db;
|
|
278
|
+
const parsed = parseSqlPayload(check.payload);
|
|
279
|
+
if (parsed.ok === false)
|
|
280
|
+
return parsed.response;
|
|
281
|
+
const limit = enforceSqlLimits(env, parsed.sql, parsed.params);
|
|
282
|
+
if (limit !== null)
|
|
283
|
+
return limit;
|
|
284
|
+
const row = await db
|
|
285
|
+
.prepare(parsed.sql)
|
|
286
|
+
.bind(...parsed.params)
|
|
287
|
+
.first();
|
|
288
|
+
return json(200, { row: row ?? null });
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
logProxyError(env, { op: 'queryOne', path: '/zin/d1/queryOne' }, error);
|
|
292
|
+
return toD1ExceptionResponse(error);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const handleExec = async (request, env) => {
|
|
296
|
+
try {
|
|
297
|
+
const check = await readAndVerifyJson(request, env);
|
|
298
|
+
if (check.ok === false)
|
|
299
|
+
return check.response;
|
|
300
|
+
const db = requireDb(env);
|
|
301
|
+
if (db instanceof Response)
|
|
302
|
+
return db;
|
|
303
|
+
const parsed = parseSqlPayload(check.payload);
|
|
304
|
+
if (parsed.ok === false)
|
|
305
|
+
return parsed.response;
|
|
306
|
+
const limit = enforceSqlLimits(env, parsed.sql, parsed.params);
|
|
307
|
+
if (limit !== null)
|
|
308
|
+
return limit;
|
|
309
|
+
const out = await db
|
|
310
|
+
.prepare(parsed.sql)
|
|
311
|
+
.bind(...parsed.params)
|
|
312
|
+
.run();
|
|
313
|
+
return json(200, { ok: true, meta: out.meta });
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
logProxyError(env, { op: 'exec', path: '/zin/d1/exec' }, error);
|
|
317
|
+
return toD1ExceptionResponse(error);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const parseStatementPayload = (payload) => {
|
|
321
|
+
if (!isRecord(payload)) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
response: toErrorResponse(400, 'VALIDATION_ERROR', 'Invalid body'),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const statementId = payload['statementId'];
|
|
328
|
+
const params = payload['params'];
|
|
329
|
+
if (!isString(statementId) || statementId.trim() === '') {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
response: toErrorResponse(400, 'VALIDATION_ERROR', 'statementId must be a string'),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, statementId, params: isArray(params) ? params : [] };
|
|
336
|
+
};
|
|
337
|
+
const handleStatement = async (request, env) => {
|
|
338
|
+
try {
|
|
339
|
+
const check = await readAndVerifyJson(request, env);
|
|
340
|
+
if (check.ok === false)
|
|
341
|
+
return check.response;
|
|
342
|
+
const db = requireDb(env);
|
|
343
|
+
if (db instanceof Response)
|
|
344
|
+
return db;
|
|
345
|
+
const statements = loadStatements(env);
|
|
346
|
+
if (statements === null) {
|
|
347
|
+
return toErrorResponse(400, 'CONFIG_ERROR', 'Missing or invalid ZT_D1_STATEMENTS_JSON');
|
|
348
|
+
}
|
|
349
|
+
const parsed = parseStatementPayload(check.payload);
|
|
350
|
+
if (parsed.ok === false)
|
|
351
|
+
return parsed.response;
|
|
352
|
+
const sql = statements[parsed.statementId];
|
|
353
|
+
if (!isString(sql) || sql.trim() === '') {
|
|
354
|
+
return toErrorResponse(404, 'NOT_FOUND', 'Unknown statementId');
|
|
355
|
+
}
|
|
356
|
+
if (isMutatingSql(sql)) {
|
|
357
|
+
const out = await db
|
|
358
|
+
.prepare(sql)
|
|
359
|
+
.bind(...parsed.params)
|
|
360
|
+
.run();
|
|
361
|
+
return json(200, { ok: true, meta: out.meta });
|
|
362
|
+
}
|
|
363
|
+
const out = await db
|
|
364
|
+
.prepare(sql)
|
|
365
|
+
.bind(...parsed.params)
|
|
366
|
+
.all();
|
|
367
|
+
const rows = out.results ?? [];
|
|
368
|
+
return json(200, { rows, rowCount: rows.length });
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
logProxyError(env, { op: 'statement', path: '/zin/d1/statement' }, error);
|
|
372
|
+
return toD1ExceptionResponse(error);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
export const ZintrustD1Proxy = Object.freeze({
|
|
376
|
+
_ZINTRUST_CLOUDFLARE_D1_PROXY_VERSION: '0.1.15',
|
|
377
|
+
_ZINTRUST_CLOUDFLARE_D1_PROXY_BUILD_DATE: '2026-04-20T20:59:39.840Z',
|
|
378
|
+
async fetch(request, env) {
|
|
379
|
+
const url = new URL(request.url);
|
|
380
|
+
const methodError = RequestValidator.requirePost(request.method);
|
|
381
|
+
if (methodError !== null) {
|
|
382
|
+
return toErrorResponse(405, methodError.code, 'Method not allowed');
|
|
383
|
+
}
|
|
384
|
+
switch (url.pathname) {
|
|
385
|
+
case '/zin/d1/query':
|
|
386
|
+
return handleQuery(request, env);
|
|
387
|
+
case '/zin/d1/queryOne':
|
|
388
|
+
return handleQueryOne(request, env);
|
|
389
|
+
case '/zin/d1/exec':
|
|
390
|
+
return handleExec(request, env);
|
|
391
|
+
case '/zin/d1/statement':
|
|
392
|
+
return handleStatement(request, env);
|
|
393
|
+
default:
|
|
394
|
+
return toErrorResponse(404, 'NOT_FOUND', 'Not found');
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
export default ZintrustD1Proxy;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zintrust/cloudflare-d1-proxy",
|
|
3
|
+
"version": "0.7.9",
|
|
4
|
+
"description": "Cloudflare D1 proxy package for ZinTrust.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@zintrust/core": "^0.7.9"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"zintrust",
|
|
27
|
+
"cloudflare",
|
|
28
|
+
"d1",
|
|
29
|
+
"proxy",
|
|
30
|
+
"workers"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
|
|
34
|
+
"type-check": "tsc -p tsconfig.json --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|