@smithers-orchestrator/electric-proxy 0.25.0
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/LICENSE +21 -0
- package/bin/smithers-electric-proxy.ts +81 -0
- package/package.json +37 -0
- package/src/createSmithersElectricProxy.ts +709 -0
- package/src/createSmithersElectricProxyMetrics.ts +82 -0
- package/src/createSmithersElectricProxyObserver.ts +88 -0
- package/src/index.ts +29 -0
- package/src/serveSmithersElectricProxy.ts +90 -0
- package/src/smithersElectricShapeCatalog.ts +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 William Cory
|
|
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.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runnable cloud entry point for the Smithers Electric proxy. Fronts a real
|
|
4
|
+
* `electricsql/electric` service (SMITHERS_ELECTRIC_URL) with auth, scope, and
|
|
5
|
+
* grant-based shape filtering, deriving each caller's grants from the gateway
|
|
6
|
+
* (SMITHERS_GATEWAY_URL): the set of runs a bearer token can read IS its
|
|
7
|
+
* granted run ids. Designed to run alongside the deploy/electric stack.
|
|
8
|
+
*
|
|
9
|
+
* SMITHERS_ELECTRIC_URL=http://electric:3000/v1/shape \
|
|
10
|
+
* SMITHERS_GATEWAY_URL=http://gateway:7342 \
|
|
11
|
+
* SMITHERS_ELECTRIC_PROXY_PORT=8443 \
|
|
12
|
+
* node bin/smithers-electric-proxy.ts
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
createSmithersElectricProxy,
|
|
16
|
+
type SmithersElectricAuthContext,
|
|
17
|
+
} from "../src/createSmithersElectricProxy.ts";
|
|
18
|
+
import { serveSmithersElectricProxy } from "../src/serveSmithersElectricProxy.ts";
|
|
19
|
+
|
|
20
|
+
function requireEnv(name: string): string {
|
|
21
|
+
const value = process.env[name];
|
|
22
|
+
if (!value) {
|
|
23
|
+
throw new Error(`${name} is required`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function deriveGrantsFromGateway(
|
|
29
|
+
gatewayUrl: string,
|
|
30
|
+
authorization: string,
|
|
31
|
+
): Promise<SmithersElectricAuthContext | null> {
|
|
32
|
+
// listRuns returns exactly the runs the token is authorized to read, so its
|
|
33
|
+
// result is the authoritative grant set. A 401/403 means no access.
|
|
34
|
+
const response = await fetch(`${gatewayUrl.replace(/\/+$/, "")}/v1/rpc/listRuns`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "content-type": "application/json", authorization },
|
|
37
|
+
body: JSON.stringify({}),
|
|
38
|
+
}).catch(() => null);
|
|
39
|
+
if (!response || !response.ok) return null;
|
|
40
|
+
const body = (await response.json().catch(() => null)) as { payload?: unknown } | null;
|
|
41
|
+
const payload = body?.payload;
|
|
42
|
+
const rows = Array.isArray(payload) ? payload : [];
|
|
43
|
+
const grantedRunIds = rows
|
|
44
|
+
.map((row) => (row && typeof row === "object" ? (row as { runId?: unknown }).runId : undefined))
|
|
45
|
+
.filter((id): id is string => typeof id === "string");
|
|
46
|
+
return {
|
|
47
|
+
principalId: authorization.slice(-12),
|
|
48
|
+
scopes: ["run:read"],
|
|
49
|
+
grantedRunIds,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main(): Promise<void> {
|
|
54
|
+
const electricUrl = requireEnv("SMITHERS_ELECTRIC_URL");
|
|
55
|
+
const gatewayUrl = requireEnv("SMITHERS_GATEWAY_URL");
|
|
56
|
+
const port = Number(process.env.SMITHERS_ELECTRIC_PROXY_PORT ?? 8443);
|
|
57
|
+
const outputTables = (process.env.SMITHERS_ELECTRIC_OUTPUT_TABLES ?? "")
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((table) => table.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
|
|
62
|
+
const proxy = createSmithersElectricProxy({
|
|
63
|
+
electricUrl,
|
|
64
|
+
outputTables,
|
|
65
|
+
authenticate: async (request) => {
|
|
66
|
+
const authorization = request.headers.get("authorization");
|
|
67
|
+
if (!authorization) return null;
|
|
68
|
+
return deriveGrantsFromGateway(gatewayUrl, authorization);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const { port: boundPort } = await serveSmithersElectricProxy({ proxy, port });
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.log(`smithers-electric-proxy listening on :${boundPort} -> ${electricUrl}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smithers-orchestrator/electric-proxy",
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"description": "Auth, scope, rate-limit, and observability proxy for Smithers ElectricSQL Shapes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"bin": {
|
|
8
|
+
"smithers-electric-proxy": "./bin/smithers-electric-proxy.ts"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"./*": {
|
|
17
|
+
"types": "./src/*.ts",
|
|
18
|
+
"import": "./src/*.ts",
|
|
19
|
+
"default": "./src/*.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src/",
|
|
24
|
+
"bin/"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@smithers-orchestrator/gateway": "0.25.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"typescript": "~5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"test": "bun test tests"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { hasGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
|
|
2
|
+
import type { GatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
|
|
3
|
+
import {
|
|
4
|
+
createSmithersElectricProxyMetrics,
|
|
5
|
+
type SmithersElectricProxyMetrics,
|
|
6
|
+
} from "./createSmithersElectricProxyMetrics.ts";
|
|
7
|
+
import {
|
|
8
|
+
smithersElectricCatalogWithOutputTables,
|
|
9
|
+
type SmithersElectricShapeDefinition,
|
|
10
|
+
} from "./smithersElectricShapeCatalog.ts";
|
|
11
|
+
import {
|
|
12
|
+
emitSmithersElectricEvent,
|
|
13
|
+
type SmithersElectricProxyObserver,
|
|
14
|
+
} from "./createSmithersElectricProxyObserver.ts";
|
|
15
|
+
|
|
16
|
+
export type SmithersElectricAuthContext = {
|
|
17
|
+
principalId?: string;
|
|
18
|
+
userId?: string;
|
|
19
|
+
tokenId?: string;
|
|
20
|
+
scopes: readonly string[];
|
|
21
|
+
grantedRunIds?: readonly string[];
|
|
22
|
+
grantedWorkspaceIds?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Single-user local-cloud installs (one tenant, no per-run partitioning) can
|
|
25
|
+
* opt OUT of run/workspace scoping by setting this. Absent or false, the
|
|
26
|
+
* proxy fails CLOSED: a run/workspace-scoped shape with no concrete grant
|
|
27
|
+
* array is rejected rather than forwarded unscoped. Cloud auth must derive
|
|
28
|
+
* concrete grants and leave this unset.
|
|
29
|
+
*/
|
|
30
|
+
unscoped?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SmithersElectricScopeDecision = {
|
|
34
|
+
event: "smithers-electric.scope";
|
|
35
|
+
allowed: boolean;
|
|
36
|
+
reason: string;
|
|
37
|
+
table: string;
|
|
38
|
+
shape: string;
|
|
39
|
+
requiredScope: GatewayScope;
|
|
40
|
+
principalId: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SmithersElectricProxyOptions = {
|
|
44
|
+
electricUrl: string;
|
|
45
|
+
authenticate: (request: Request) => Promise<SmithersElectricAuthContext | null> | SmithersElectricAuthContext | null;
|
|
46
|
+
fetchClient?: typeof fetch;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
rateLimits?: {
|
|
49
|
+
openPerMinute?: number;
|
|
50
|
+
activeMax?: number;
|
|
51
|
+
};
|
|
52
|
+
maxFrameBytes?: number;
|
|
53
|
+
catalog?: readonly SmithersElectricShapeDefinition[];
|
|
54
|
+
/**
|
|
55
|
+
* Explicit allowlist of workflow output-table names that may be opened as
|
|
56
|
+
* run-scoped shapes. Empty (the default) exposes NO output tables. Derive
|
|
57
|
+
* this from the real output-table registry — never a regex catch-all.
|
|
58
|
+
*/
|
|
59
|
+
outputTables?: readonly string[];
|
|
60
|
+
/**
|
|
61
|
+
* Reclaim an active-shape slot whose stream never started draining after this
|
|
62
|
+
* many ms. Without it, a client that opens shapes but never reads or cancels
|
|
63
|
+
* the body holds active slots forever and self-DoSes with permanent 429s.
|
|
64
|
+
*/
|
|
65
|
+
activeTtlMs?: number;
|
|
66
|
+
metrics?: SmithersElectricProxyMetrics;
|
|
67
|
+
observer?: SmithersElectricProxyObserver;
|
|
68
|
+
log?: (decision: SmithersElectricScopeDecision) => void;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type SmithersElectricProxy = {
|
|
72
|
+
fetch(request: Request): Promise<Response>;
|
|
73
|
+
metrics: SmithersElectricProxyMetrics;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ParsedWhere = {
|
|
77
|
+
values: Map<string, string[]>;
|
|
78
|
+
isNull: Set<string>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type OpenBucket = {
|
|
82
|
+
windowStartMs: number;
|
|
83
|
+
count: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const DEFAULT_OPEN_PER_MINUTE = 60;
|
|
87
|
+
const DEFAULT_ACTIVE_MAX = 50;
|
|
88
|
+
const DEFAULT_MAX_FRAME_BYTES = 4 * 1024 * 1024;
|
|
89
|
+
const DEFAULT_ACTIVE_TTL_MS = 5 * 60_000;
|
|
90
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
91
|
+
|
|
92
|
+
function json(status: number, payload: unknown, headers?: HeadersInit): Response {
|
|
93
|
+
return new Response(JSON.stringify(payload), {
|
|
94
|
+
status,
|
|
95
|
+
headers: { ...JSON_HEADERS, ...Object.fromEntries(new Headers(headers)) },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function principalId(auth: SmithersElectricAuthContext): string {
|
|
100
|
+
return auth.principalId ?? auth.userId ?? auth.tokenId ?? "anonymous";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function q(value: string): string {
|
|
104
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listLiteral(values: readonly string[]): string {
|
|
108
|
+
return values.map(q).join(",");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseCsvList(value: string | null): string[] {
|
|
112
|
+
if (!value) return [];
|
|
113
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasDuplicateSecurityParam(params: URLSearchParams): string | null {
|
|
117
|
+
for (const name of ["table", "shape", "where", "key"]) {
|
|
118
|
+
if (params.getAll(name).length > 1) return name;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeIdentifier(identifier: string): string {
|
|
124
|
+
return identifier.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tokenizeWhere(where: string): string[] {
|
|
128
|
+
const tokens: string[] = [];
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < where.length) {
|
|
131
|
+
const ch = where[i];
|
|
132
|
+
if (/\s/.test(ch)) {
|
|
133
|
+
i += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "-" && where[i + 1] === "-") {
|
|
137
|
+
throw new Error("comments are not allowed in shape where clauses");
|
|
138
|
+
}
|
|
139
|
+
if (ch === "/" && where[i + 1] === "*") {
|
|
140
|
+
throw new Error("comments are not allowed in shape where clauses");
|
|
141
|
+
}
|
|
142
|
+
if ("(),=".includes(ch)) {
|
|
143
|
+
tokens.push(ch);
|
|
144
|
+
i += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === "'" || ch === `"`) {
|
|
148
|
+
const quote = ch;
|
|
149
|
+
i += 1;
|
|
150
|
+
let value = "";
|
|
151
|
+
while (i < where.length && where[i] !== quote) {
|
|
152
|
+
if (where[i] === "\\") throw new Error("backslash escapes are not allowed in shape where clauses");
|
|
153
|
+
value += where[i];
|
|
154
|
+
i += 1;
|
|
155
|
+
}
|
|
156
|
+
if (where[i] !== quote) throw new Error("unterminated string literal in shape where clause");
|
|
157
|
+
i += 1;
|
|
158
|
+
tokens.push(JSON.stringify(value));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
162
|
+
const start = i;
|
|
163
|
+
i += 1;
|
|
164
|
+
while (i < where.length && /[A-Za-z0-9_]/.test(where[i])) i += 1;
|
|
165
|
+
tokens.push(normalizeIdentifier(where.slice(start, i)));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/[0-9]/.test(ch)) {
|
|
169
|
+
const start = i;
|
|
170
|
+
i += 1;
|
|
171
|
+
while (i < where.length && /[0-9]/.test(where[i])) i += 1;
|
|
172
|
+
tokens.push(where.slice(start, i));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`unexpected character ${JSON.stringify(ch)} in shape where clause`);
|
|
176
|
+
}
|
|
177
|
+
return tokens;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function tokenValue(token: string): string {
|
|
181
|
+
if (token.startsWith("\"")) return JSON.parse(token) as string;
|
|
182
|
+
return token;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseWhere(where: string): ParsedWhere {
|
|
186
|
+
const tokens = tokenizeWhere(where);
|
|
187
|
+
const values = new Map<string, string[]>();
|
|
188
|
+
const isNull = new Set<string>();
|
|
189
|
+
let i = 0;
|
|
190
|
+
const peek = () => tokens[i];
|
|
191
|
+
const take = (expected?: string): string => {
|
|
192
|
+
const token = tokens[i];
|
|
193
|
+
if (token === undefined) throw new Error(`expected ${expected ?? "token"}, got end of where clause`);
|
|
194
|
+
if (expected !== undefined && token !== expected) throw new Error(`expected ${expected}, got ${token}`);
|
|
195
|
+
i += 1;
|
|
196
|
+
return token;
|
|
197
|
+
};
|
|
198
|
+
const takeValue = () => {
|
|
199
|
+
const token = take();
|
|
200
|
+
if (["and", "or", "union", "select", "not", "in", "is", "null", "=", "(", ")", ","].includes(token)) {
|
|
201
|
+
throw new Error(`expected literal value, got ${token}`);
|
|
202
|
+
}
|
|
203
|
+
return tokenValue(token);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
while (i < tokens.length) {
|
|
207
|
+
if (["or", "union", "select", "not"].includes(peek())) {
|
|
208
|
+
throw new Error(`${peek().toUpperCase()} is not allowed in shape where clauses`);
|
|
209
|
+
}
|
|
210
|
+
const column = take();
|
|
211
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(column)) throw new Error(`invalid where column ${column}`);
|
|
212
|
+
const op = take();
|
|
213
|
+
if (op === "=") {
|
|
214
|
+
values.set(column, [takeValue()]);
|
|
215
|
+
} else if (op === "in") {
|
|
216
|
+
take("(");
|
|
217
|
+
const list: string[] = [];
|
|
218
|
+
for (;;) {
|
|
219
|
+
list.push(takeValue());
|
|
220
|
+
if (peek() === ",") {
|
|
221
|
+
take(",");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
take(")");
|
|
227
|
+
values.set(column, list);
|
|
228
|
+
} else if (op === "is") {
|
|
229
|
+
take("null");
|
|
230
|
+
isNull.add(column);
|
|
231
|
+
} else {
|
|
232
|
+
throw new Error(`unsupported where operator ${op}`);
|
|
233
|
+
}
|
|
234
|
+
if (i >= tokens.length) break;
|
|
235
|
+
take("and");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { values, isNull };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function shapeForTable(
|
|
242
|
+
catalog: readonly SmithersElectricShapeDefinition[],
|
|
243
|
+
table: string,
|
|
244
|
+
shapeName?: string | null,
|
|
245
|
+
): SmithersElectricShapeDefinition | undefined {
|
|
246
|
+
if (shapeName) {
|
|
247
|
+
return catalog.find((shape) => shape.name === shapeName && (shape.table === table || shape.table === "*" || table === ""));
|
|
248
|
+
}
|
|
249
|
+
return catalog.find((shape) => shape.table === table) ??
|
|
250
|
+
catalog.find((shape) => shape.tablePattern?.test(table));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fillWhereTemplate(
|
|
254
|
+
shape: SmithersElectricShapeDefinition,
|
|
255
|
+
auth: SmithersElectricAuthContext,
|
|
256
|
+
): string | null {
|
|
257
|
+
if (!shape.whereTemplate) return null;
|
|
258
|
+
let where = shape.whereTemplate;
|
|
259
|
+
if (where.includes("{run_ids}")) {
|
|
260
|
+
const runIds = auth.grantedRunIds ?? [];
|
|
261
|
+
if (runIds.length === 0) return null;
|
|
262
|
+
where = where.replaceAll("{run_ids}", listLiteral(runIds));
|
|
263
|
+
}
|
|
264
|
+
if (where.includes("{workspace_ids}")) {
|
|
265
|
+
const workspaceIds = auth.grantedWorkspaceIds ?? [];
|
|
266
|
+
if (workspaceIds.length === 0) return null;
|
|
267
|
+
where = where.replaceAll("{workspace_ids}", listLiteral(workspaceIds));
|
|
268
|
+
}
|
|
269
|
+
if (where.includes("{user_id}")) {
|
|
270
|
+
if (!auth.userId) return null;
|
|
271
|
+
where = where.replaceAll("{user_id}", q(auth.userId));
|
|
272
|
+
}
|
|
273
|
+
return where;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function ensureValuesAllowed(
|
|
277
|
+
column: string,
|
|
278
|
+
requested: readonly string[],
|
|
279
|
+
granted: readonly string[] | undefined,
|
|
280
|
+
unscoped: boolean,
|
|
281
|
+
): void {
|
|
282
|
+
// A single-user local-cloud install (no per-run partitioning) may opt out of
|
|
283
|
+
// scoping entirely. Otherwise this column is a scoping boundary and an
|
|
284
|
+
// undefined grant array means "no access derived" — FAIL CLOSED rather than
|
|
285
|
+
// forwarding an arbitrary client-supplied predicate.
|
|
286
|
+
if (unscoped) return;
|
|
287
|
+
if (!granted) throw new Error(`${column} scoping grants are required`);
|
|
288
|
+
if (requested.length === 0) throw new Error(`${column} predicate is required`);
|
|
289
|
+
const allowed = new Set(granted.map(String));
|
|
290
|
+
for (const value of requested) {
|
|
291
|
+
if (!allowed.has(String(value))) {
|
|
292
|
+
throw new Error(`${column} predicate includes an unauthorized value`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateWhere(
|
|
298
|
+
shape: SmithersElectricShapeDefinition,
|
|
299
|
+
where: string | null,
|
|
300
|
+
auth: SmithersElectricAuthContext,
|
|
301
|
+
): string | null {
|
|
302
|
+
const unscoped = auth.unscoped === true;
|
|
303
|
+
const effectiveWhere = where && where.trim() ? where.trim() : fillWhereTemplate(shape, auth);
|
|
304
|
+
if (!effectiveWhere) {
|
|
305
|
+
// No client where and the template could not be filled. For a scoped shape
|
|
306
|
+
// that is only acceptable when the principal is explicitly unscoped; a
|
|
307
|
+
// scoped principal with no concrete grants gets nothing.
|
|
308
|
+
if ((shape.runIdColumn || shape.workspaceIdColumn || shape.userPrivateColumn) && !unscoped) {
|
|
309
|
+
throw new Error("where clause cannot be filled from the authenticated grants");
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const parsed = parseWhere(effectiveWhere);
|
|
315
|
+
if (shape.runIdColumn) {
|
|
316
|
+
ensureValuesAllowed(shape.runIdColumn, parsed.values.get(shape.runIdColumn) ?? [], auth.grantedRunIds, unscoped);
|
|
317
|
+
}
|
|
318
|
+
if (shape.workspaceIdColumn) {
|
|
319
|
+
ensureValuesAllowed(shape.workspaceIdColumn, parsed.values.get(shape.workspaceIdColumn) ?? [], auth.grantedWorkspaceIds, unscoped);
|
|
320
|
+
}
|
|
321
|
+
if (shape.userPrivateColumn) {
|
|
322
|
+
const claimed = parsed.values.get(shape.userPrivateColumn) ?? [];
|
|
323
|
+
if (claimed.length !== 1 || !auth.userId || claimed[0] !== auth.userId) {
|
|
324
|
+
throw new Error(`${shape.userPrivateColumn} predicate must match the authenticated user`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return effectiveWhere;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
type ActiveSlot = {
|
|
331
|
+
key: string;
|
|
332
|
+
acquiredAtMs: number;
|
|
333
|
+
/** Set true once the stream actually starts draining (first pull / cancel). */
|
|
334
|
+
draining: boolean;
|
|
335
|
+
released: boolean;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
function rateLimiter(now: () => number, openPerMinute: number, activeMax: number, activeTtlMs: number) {
|
|
339
|
+
const buckets = new Map<string, OpenBucket>();
|
|
340
|
+
const active = new Map<string, Set<ActiveSlot>>();
|
|
341
|
+
const windowMs = 60_000;
|
|
342
|
+
|
|
343
|
+
// Reclaim slots whose stream never started draining within the TTL window. A
|
|
344
|
+
// client that opens a shape but never reads or cancels the body would
|
|
345
|
+
// otherwise pin a slot forever and eventually self-DoS with permanent 429s.
|
|
346
|
+
const sweepExpired = () => {
|
|
347
|
+
const current = now();
|
|
348
|
+
for (const [key, slots] of active) {
|
|
349
|
+
for (const slot of slots) {
|
|
350
|
+
if (!slot.released && !slot.draining && current - slot.acquiredAtMs >= activeTtlMs) {
|
|
351
|
+
slot.released = true;
|
|
352
|
+
slots.delete(slot);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (slots.size === 0) active.delete(key);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const countFor = (key: string) => active.get(key)?.size ?? 0;
|
|
359
|
+
const activeTotal = () => {
|
|
360
|
+
let total = 0;
|
|
361
|
+
for (const slots of active.values()) total += slots.size;
|
|
362
|
+
return total;
|
|
363
|
+
};
|
|
364
|
+
return {
|
|
365
|
+
consumeOpen(key: string): boolean {
|
|
366
|
+
const current = now();
|
|
367
|
+
const bucket = buckets.get(key);
|
|
368
|
+
if (!bucket || current - bucket.windowStartMs >= windowMs) {
|
|
369
|
+
buckets.set(key, { windowStartMs: current, count: 1 });
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (bucket.count >= openPerMinute) return false;
|
|
373
|
+
bucket.count += 1;
|
|
374
|
+
return true;
|
|
375
|
+
},
|
|
376
|
+
acquireActive(key: string): ActiveSlot | null {
|
|
377
|
+
sweepExpired();
|
|
378
|
+
if (countFor(key) >= activeMax) return null;
|
|
379
|
+
const slot: ActiveSlot = { key, acquiredAtMs: now(), draining: false, released: false };
|
|
380
|
+
const slots = active.get(key) ?? new Set<ActiveSlot>();
|
|
381
|
+
slots.add(slot);
|
|
382
|
+
active.set(key, slots);
|
|
383
|
+
return slot;
|
|
384
|
+
},
|
|
385
|
+
markDraining(slot: ActiveSlot): void {
|
|
386
|
+
slot.draining = true;
|
|
387
|
+
},
|
|
388
|
+
releaseActive(slot: ActiveSlot): void {
|
|
389
|
+
if (slot.released) return;
|
|
390
|
+
slot.released = true;
|
|
391
|
+
const slots = active.get(slot.key);
|
|
392
|
+
if (!slots) return;
|
|
393
|
+
slots.delete(slot);
|
|
394
|
+
if (slots.size === 0) active.delete(slot.key);
|
|
395
|
+
},
|
|
396
|
+
activeTotal,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function copyForwardHeaders(headers: Headers): Headers {
|
|
401
|
+
const out = new Headers();
|
|
402
|
+
for (const [key, value] of headers) {
|
|
403
|
+
const lower = key.toLowerCase();
|
|
404
|
+
if (lower === "authorization" || lower === "host" || lower === "content-length") continue;
|
|
405
|
+
out.set(key, value);
|
|
406
|
+
}
|
|
407
|
+
if (!out.has("accept")) out.set("accept", "text/event-stream");
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function responseHeaders(headers: Headers): Headers {
|
|
412
|
+
const out = new Headers(headers);
|
|
413
|
+
out.set("access-control-allow-origin", "*");
|
|
414
|
+
out.set("access-control-expose-headers", "electric-handle, electric-offset");
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Bounds the byte size of a single SSE frame (frames delimited by `\n`[\r]*`\n`)
|
|
420
|
+
* without iterating every byte: newlines are located with `indexOf` and the
|
|
421
|
+
* CR-only gap test runs only until the first data byte of each frame, so the
|
|
422
|
+
* hot path is O(frames), not O(bytes). Byte accounting stays exact so the size
|
|
423
|
+
* guard is faithful to the original per-byte loop.
|
|
424
|
+
*/
|
|
425
|
+
function createFrameBoundScanner(maxFrameBytes: number) {
|
|
426
|
+
let frameBytes = 0;
|
|
427
|
+
let seenNewline = false;
|
|
428
|
+
let gapCrOnly = true;
|
|
429
|
+
return {
|
|
430
|
+
push(chunk: Uint8Array): "ok" | "exceeded" {
|
|
431
|
+
let pos = 0;
|
|
432
|
+
while (pos < chunk.length) {
|
|
433
|
+
const nl = chunk.indexOf(10, pos);
|
|
434
|
+
const segEnd = nl === -1 ? chunk.length : nl;
|
|
435
|
+
const segLen = segEnd - pos;
|
|
436
|
+
if (segLen > 0) {
|
|
437
|
+
frameBytes += segLen;
|
|
438
|
+
if (gapCrOnly) {
|
|
439
|
+
for (let k = pos; k < segEnd; k += 1) {
|
|
440
|
+
if (chunk[k] !== 13) {
|
|
441
|
+
gapCrOnly = false;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (frameBytes > maxFrameBytes) return "exceeded";
|
|
447
|
+
}
|
|
448
|
+
if (nl === -1) break;
|
|
449
|
+
frameBytes += 1; // the '\n' itself counts toward the frame
|
|
450
|
+
if (frameBytes > maxFrameBytes) return "exceeded";
|
|
451
|
+
if (seenNewline && gapCrOnly) {
|
|
452
|
+
frameBytes = 0;
|
|
453
|
+
seenNewline = false;
|
|
454
|
+
} else {
|
|
455
|
+
seenNewline = true;
|
|
456
|
+
}
|
|
457
|
+
gapCrOnly = true;
|
|
458
|
+
pos = nl + 1;
|
|
459
|
+
}
|
|
460
|
+
return "ok";
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function wrapBody(
|
|
466
|
+
body: ReadableStream<Uint8Array> | null,
|
|
467
|
+
metrics: SmithersElectricProxyMetrics,
|
|
468
|
+
maxFrameBytes: number,
|
|
469
|
+
hooks: { onStart: () => void; release: () => void },
|
|
470
|
+
): ReadableStream<Uint8Array> | null {
|
|
471
|
+
if (!body) {
|
|
472
|
+
hooks.release();
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
let released = false;
|
|
476
|
+
const done = () => {
|
|
477
|
+
if (released) return;
|
|
478
|
+
released = true;
|
|
479
|
+
hooks.release();
|
|
480
|
+
};
|
|
481
|
+
const reader = body.getReader();
|
|
482
|
+
const scanner = createFrameBoundScanner(maxFrameBytes);
|
|
483
|
+
let started = false;
|
|
484
|
+
return new ReadableStream<Uint8Array>({
|
|
485
|
+
async pull(controller) {
|
|
486
|
+
try {
|
|
487
|
+
const chunk = await reader.read();
|
|
488
|
+
if (chunk.done) {
|
|
489
|
+
done();
|
|
490
|
+
controller.close();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const value = chunk.value;
|
|
494
|
+
// The stream has genuinely started draining only once real bytes flow.
|
|
495
|
+
// A shape that is opened but never produces a byte (abandoned/stuck) is
|
|
496
|
+
// left reclaimable by the active-slot TTL; a live Electric shape always
|
|
497
|
+
// sends its initial snapshot, so it is marked draining and held.
|
|
498
|
+
if (!started) {
|
|
499
|
+
started = true;
|
|
500
|
+
hooks.onStart();
|
|
501
|
+
}
|
|
502
|
+
metrics.addForwardedBytes(value.byteLength);
|
|
503
|
+
if (scanner.push(value) === "exceeded") {
|
|
504
|
+
metrics.incLargeFrame();
|
|
505
|
+
await reader.cancel("smithers electric frame exceeded proxy limit").catch(() => undefined);
|
|
506
|
+
done();
|
|
507
|
+
controller.error(new Error(`Electric frame exceeded ${maxFrameBytes} bytes`));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
controller.enqueue(value);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
done();
|
|
513
|
+
controller.error(error);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
async cancel(reason) {
|
|
517
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
518
|
+
done();
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function corsPreflight(): Response {
|
|
524
|
+
return new Response(null, {
|
|
525
|
+
status: 204,
|
|
526
|
+
headers: {
|
|
527
|
+
"access-control-allow-origin": "*",
|
|
528
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
529
|
+
"access-control-allow-headers": "Authorization, Content-Type",
|
|
530
|
+
"access-control-expose-headers": "electric-handle, electric-offset",
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function sanitizeQuery(
|
|
536
|
+
requestUrl: URL,
|
|
537
|
+
table: string,
|
|
538
|
+
where: string | null,
|
|
539
|
+
): URLSearchParams {
|
|
540
|
+
const params = new URLSearchParams(requestUrl.searchParams);
|
|
541
|
+
params.delete("key");
|
|
542
|
+
params.set("table", table);
|
|
543
|
+
if (where) params.set("where", where);
|
|
544
|
+
else params.delete("where");
|
|
545
|
+
params.delete("shape");
|
|
546
|
+
return params;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function createSmithersElectricProxy(options: SmithersElectricProxyOptions): SmithersElectricProxy {
|
|
550
|
+
const fetchClient = options.fetchClient ?? fetch;
|
|
551
|
+
const now = options.now ?? (() => Date.now());
|
|
552
|
+
const metrics = options.metrics ?? createSmithersElectricProxyMetrics();
|
|
553
|
+
const observer = options.observer;
|
|
554
|
+
const catalog = options.catalog ?? smithersElectricCatalogWithOutputTables(options.outputTables ?? []);
|
|
555
|
+
const limits = rateLimiter(
|
|
556
|
+
now,
|
|
557
|
+
options.rateLimits?.openPerMinute ?? DEFAULT_OPEN_PER_MINUTE,
|
|
558
|
+
options.rateLimits?.activeMax ?? DEFAULT_ACTIVE_MAX,
|
|
559
|
+
options.activeTtlMs ?? DEFAULT_ACTIVE_TTL_MS,
|
|
560
|
+
);
|
|
561
|
+
const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
|
|
562
|
+
const reject = (
|
|
563
|
+
decisionBase: Omit<SmithersElectricScopeDecision, "allowed" | "reason">,
|
|
564
|
+
reason: string,
|
|
565
|
+
) => {
|
|
566
|
+
options.log?.({ ...decisionBase, allowed: false, reason });
|
|
567
|
+
metrics.incShapeOpenRejected();
|
|
568
|
+
emitSmithersElectricEvent(observer, {
|
|
569
|
+
type: "electric.shape.rejected",
|
|
570
|
+
principalId: decisionBase.principalId,
|
|
571
|
+
table: decisionBase.table,
|
|
572
|
+
shape: decisionBase.shape,
|
|
573
|
+
requiredScope: decisionBase.requiredScope,
|
|
574
|
+
reason,
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
async function handleShape(request: Request, requestUrl: URL): Promise<Response> {
|
|
579
|
+
const duplicate = hasDuplicateSecurityParam(requestUrl.searchParams);
|
|
580
|
+
if (duplicate) {
|
|
581
|
+
metrics.incShapeOpenRejected();
|
|
582
|
+
return json(400, { error: `duplicate ${duplicate} query parameter` });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const auth = await options.authenticate(request);
|
|
586
|
+
if (!auth) {
|
|
587
|
+
metrics.incShapeOpenRejected();
|
|
588
|
+
return json(401, { error: "authentication required" });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const table = requestUrl.searchParams.get("table") ?? "";
|
|
592
|
+
const shape = shapeForTable(catalog, table, requestUrl.searchParams.get("shape"));
|
|
593
|
+
if (!shape || (!table && shape.table === "*")) {
|
|
594
|
+
metrics.incShapeOpenRejected();
|
|
595
|
+
return json(404, { error: "shape not found" });
|
|
596
|
+
}
|
|
597
|
+
const effectiveTable = table || shape.table;
|
|
598
|
+
const principal = principalId(auth);
|
|
599
|
+
const allowedByScope = hasGatewayScope(auth.scopes, shape.requiredScope, "listRuns");
|
|
600
|
+
const decisionBase = {
|
|
601
|
+
event: "smithers-electric.scope" as const,
|
|
602
|
+
table: effectiveTable,
|
|
603
|
+
shape: shape.name,
|
|
604
|
+
requiredScope: shape.requiredScope,
|
|
605
|
+
principalId: principal,
|
|
606
|
+
};
|
|
607
|
+
if (!allowedByScope) {
|
|
608
|
+
reject(decisionBase, "missing required scope");
|
|
609
|
+
return json(403, { error: "missing required gateway scope", requiredScope: shape.requiredScope });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
let where: string | null;
|
|
613
|
+
try {
|
|
614
|
+
where = validateWhere(shape, requestUrl.searchParams.get("where"), auth);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
reject(decisionBase, error instanceof Error ? error.message : String(error));
|
|
617
|
+
return json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!limits.consumeOpen(principal)) {
|
|
621
|
+
reject(decisionBase, "shape open rate limit exceeded");
|
|
622
|
+
return json(429, { error: "shape open rate limit exceeded" }, { "retry-after": "60" });
|
|
623
|
+
}
|
|
624
|
+
const slot = limits.acquireActive(principal);
|
|
625
|
+
if (!slot) {
|
|
626
|
+
reject(decisionBase, "active shape limit exceeded");
|
|
627
|
+
return json(429, { error: "too many active shape subscriptions" }, { "retry-after": "1" });
|
|
628
|
+
}
|
|
629
|
+
metrics.setActiveShapes(limits.activeTotal());
|
|
630
|
+
|
|
631
|
+
const release = () => {
|
|
632
|
+
limits.releaseActive(slot);
|
|
633
|
+
metrics.setActiveShapes(limits.activeTotal());
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
options.log?.({ ...decisionBase, allowed: true, reason: "authorized" });
|
|
637
|
+
metrics.incShapeOpen();
|
|
638
|
+
const startedAtMs = now();
|
|
639
|
+
emitSmithersElectricEvent(observer, {
|
|
640
|
+
type: "electric.shape.open",
|
|
641
|
+
principalId: principal,
|
|
642
|
+
table: effectiveTable,
|
|
643
|
+
shape: shape.name,
|
|
644
|
+
requiredScope: shape.requiredScope,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const upstreamUrl = new URL(options.electricUrl);
|
|
648
|
+
upstreamUrl.search = sanitizeQuery(requestUrl, effectiveTable, where).toString();
|
|
649
|
+
const response = await fetchClient(upstreamUrl, {
|
|
650
|
+
method: request.method,
|
|
651
|
+
headers: copyForwardHeaders(request.headers),
|
|
652
|
+
signal: request.signal,
|
|
653
|
+
}).catch((error) => {
|
|
654
|
+
release();
|
|
655
|
+
throw error;
|
|
656
|
+
});
|
|
657
|
+
const lagHeader = response.headers.get("x-electric-lag-ms") ?? response.headers.get("electric-lag-ms");
|
|
658
|
+
const lag = lagHeader ? Number(lagHeader) : Number.NaN;
|
|
659
|
+
if (Number.isFinite(lag)) metrics.observeSyncLag(lag);
|
|
660
|
+
if (response.status === 409 || response.status === 410) metrics.incReplayGap();
|
|
661
|
+
|
|
662
|
+
return new Response(
|
|
663
|
+
wrapBody(response.body, metrics, maxFrameBytes, {
|
|
664
|
+
onStart: () => limits.markDraining(slot),
|
|
665
|
+
release: () => {
|
|
666
|
+
release();
|
|
667
|
+
emitSmithersElectricEvent(observer, {
|
|
668
|
+
type: "electric.shape.forwarded",
|
|
669
|
+
principalId: principal,
|
|
670
|
+
table: effectiveTable,
|
|
671
|
+
shape: shape.name,
|
|
672
|
+
status: response.status,
|
|
673
|
+
durationMs: now() - startedAtMs,
|
|
674
|
+
forwardedBytes: metrics.snapshot().forwardedBytes,
|
|
675
|
+
lagMs: Number.isFinite(lag) ? lag : undefined,
|
|
676
|
+
});
|
|
677
|
+
},
|
|
678
|
+
}),
|
|
679
|
+
{
|
|
680
|
+
status: response.status,
|
|
681
|
+
statusText: response.statusText,
|
|
682
|
+
headers: responseHeaders(response.headers),
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
metrics,
|
|
689
|
+
async fetch(request: Request): Promise<Response> {
|
|
690
|
+
const requestUrl = new URL(request.url);
|
|
691
|
+
if (request.method === "OPTIONS") return corsPreflight();
|
|
692
|
+
if (requestUrl.pathname === "/healthz") return json(200, { status: "ok" });
|
|
693
|
+
if (requestUrl.pathname === "/metrics") {
|
|
694
|
+
return new Response(metrics.renderPrometheus(), {
|
|
695
|
+
status: 200,
|
|
696
|
+
headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (requestUrl.pathname !== "/v1/shape") return json(404, { error: "not found" });
|
|
700
|
+
if (request.method !== "GET") return json(405, { error: "method not allowed" });
|
|
701
|
+
try {
|
|
702
|
+
return await handleShape(request, requestUrl);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
metrics.incShapeOpenRejected();
|
|
705
|
+
return json(502, { error: "upstream service unavailable", message: error instanceof Error ? error.message : String(error) });
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type SmithersElectricProxyMetricSnapshot = {
|
|
2
|
+
shapeOpens: number;
|
|
3
|
+
shapeOpenRejected: number;
|
|
4
|
+
activeShapes: number;
|
|
5
|
+
replayGaps: number;
|
|
6
|
+
largeFrames: number;
|
|
7
|
+
forwardedBytes: number;
|
|
8
|
+
lastSyncLagMs: number | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type SmithersElectricProxyMetrics = {
|
|
12
|
+
snapshot(): SmithersElectricProxyMetricSnapshot;
|
|
13
|
+
incShapeOpen(): void;
|
|
14
|
+
incShapeOpenRejected(): void;
|
|
15
|
+
incReplayGap(): void;
|
|
16
|
+
incLargeFrame(): void;
|
|
17
|
+
addForwardedBytes(bytes: number): void;
|
|
18
|
+
setActiveShapes(count: number): void;
|
|
19
|
+
observeSyncLag(ms: number): void;
|
|
20
|
+
renderPrometheus(): string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function createSmithersElectricProxyMetrics(): SmithersElectricProxyMetrics {
|
|
24
|
+
const state: SmithersElectricProxyMetricSnapshot = {
|
|
25
|
+
shapeOpens: 0,
|
|
26
|
+
shapeOpenRejected: 0,
|
|
27
|
+
activeShapes: 0,
|
|
28
|
+
replayGaps: 0,
|
|
29
|
+
largeFrames: 0,
|
|
30
|
+
forwardedBytes: 0,
|
|
31
|
+
lastSyncLagMs: null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
snapshot: () => ({ ...state }),
|
|
36
|
+
incShapeOpen: () => {
|
|
37
|
+
state.shapeOpens += 1;
|
|
38
|
+
},
|
|
39
|
+
incShapeOpenRejected: () => {
|
|
40
|
+
state.shapeOpenRejected += 1;
|
|
41
|
+
},
|
|
42
|
+
incReplayGap: () => {
|
|
43
|
+
state.replayGaps += 1;
|
|
44
|
+
},
|
|
45
|
+
incLargeFrame: () => {
|
|
46
|
+
state.largeFrames += 1;
|
|
47
|
+
},
|
|
48
|
+
addForwardedBytes: (bytes) => {
|
|
49
|
+
state.forwardedBytes += Math.max(0, Math.floor(bytes));
|
|
50
|
+
},
|
|
51
|
+
setActiveShapes: (count) => {
|
|
52
|
+
state.activeShapes = Math.max(0, Math.floor(count));
|
|
53
|
+
},
|
|
54
|
+
observeSyncLag: (ms) => {
|
|
55
|
+
if (Number.isFinite(ms) && ms >= 0) state.lastSyncLagMs = Math.floor(ms);
|
|
56
|
+
},
|
|
57
|
+
renderPrometheus: () => [
|
|
58
|
+
"# HELP smithers_electric_shape_opens_total Electric shape opens accepted by the Smithers proxy.",
|
|
59
|
+
"# TYPE smithers_electric_shape_opens_total counter",
|
|
60
|
+
`smithers_electric_shape_opens_total ${state.shapeOpens}`,
|
|
61
|
+
"# HELP smithers_electric_shape_open_rejected_total Electric shape opens rejected by auth, scope, or rate limits.",
|
|
62
|
+
"# TYPE smithers_electric_shape_open_rejected_total counter",
|
|
63
|
+
`smithers_electric_shape_open_rejected_total ${state.shapeOpenRejected}`,
|
|
64
|
+
"# HELP smithers_electric_active_shapes Active Electric shape streams through this proxy process.",
|
|
65
|
+
"# TYPE smithers_electric_active_shapes gauge",
|
|
66
|
+
`smithers_electric_active_shapes ${state.activeShapes}`,
|
|
67
|
+
"# HELP smithers_electric_replay_gaps_total Electric replay gaps observed by the proxy.",
|
|
68
|
+
"# TYPE smithers_electric_replay_gaps_total counter",
|
|
69
|
+
`smithers_electric_replay_gaps_total ${state.replayGaps}`,
|
|
70
|
+
"# HELP smithers_electric_large_frames_total Electric frames rejected for exceeding the proxy frame bound.",
|
|
71
|
+
"# TYPE smithers_electric_large_frames_total counter",
|
|
72
|
+
`smithers_electric_large_frames_total ${state.largeFrames}`,
|
|
73
|
+
"# HELP smithers_electric_forwarded_bytes_total Response bytes forwarded through the Electric proxy.",
|
|
74
|
+
"# TYPE smithers_electric_forwarded_bytes_total counter",
|
|
75
|
+
`smithers_electric_forwarded_bytes_total ${state.forwardedBytes}`,
|
|
76
|
+
"# HELP smithers_electric_sync_lag_ms Last observed Electric sync lag in milliseconds.",
|
|
77
|
+
"# TYPE smithers_electric_sync_lag_ms gauge",
|
|
78
|
+
`smithers_electric_sync_lag_ms ${state.lastSyncLagMs ?? 0}`,
|
|
79
|
+
"",
|
|
80
|
+
].join("\n"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability seam for the Electric proxy. Mirrors the gateway-client sync
|
|
3
|
+
* telemetry convention (`__smithersSyncTelemetry`): a pluggable sink with
|
|
4
|
+
* `event` (structured event) and `span` (OTLP-shaped span) callbacks that
|
|
5
|
+
* defaults to a global the cloud deployment wires to its OTLP exporter, and
|
|
6
|
+
* never throws on the hot path.
|
|
7
|
+
*
|
|
8
|
+
* The design (§5.3, §10) asks the Electric path to emit structured events +
|
|
9
|
+
* OTLP spans for shape opens, forwarding, and write commits. Keeping it a seam
|
|
10
|
+
* means the self-hosted proxy emits nothing by default (zero deps on an OTLP
|
|
11
|
+
* runtime) while the cloud proxy registers a real exporter.
|
|
12
|
+
*/
|
|
13
|
+
export type SmithersElectricProxyEvent = {
|
|
14
|
+
type:
|
|
15
|
+
| "electric.shape.open"
|
|
16
|
+
| "electric.shape.rejected"
|
|
17
|
+
| "electric.shape.forwarded"
|
|
18
|
+
| "electric.write.commit"
|
|
19
|
+
| "electric.write.rejected";
|
|
20
|
+
principalId: string;
|
|
21
|
+
table?: string;
|
|
22
|
+
shape?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
requiredScope?: string;
|
|
25
|
+
status?: number;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
forwardedBytes?: number;
|
|
28
|
+
lagMs?: number;
|
|
29
|
+
txid?: number | null;
|
|
30
|
+
method?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SmithersElectricProxySpan = {
|
|
34
|
+
name: string;
|
|
35
|
+
attributes: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SmithersElectricProxyObserver = {
|
|
39
|
+
event?: (event: SmithersElectricProxyEvent) => void;
|
|
40
|
+
span?: (span: SmithersElectricProxySpan) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function globalObserver(): SmithersElectricProxyObserver | undefined {
|
|
44
|
+
return (globalThis as { __smithersElectricTelemetry?: SmithersElectricProxyObserver }).__smithersElectricTelemetry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Emit a proxy event + its derived OTLP span to the supplied observer, then to
|
|
49
|
+
* the global sink. Telemetry must never break a shape open or a write, so every
|
|
50
|
+
* sink call is guarded.
|
|
51
|
+
*/
|
|
52
|
+
export function emitSmithersElectricEvent(
|
|
53
|
+
observer: SmithersElectricProxyObserver | undefined,
|
|
54
|
+
event: SmithersElectricProxyEvent,
|
|
55
|
+
): void {
|
|
56
|
+
const sinks = [observer, globalObserver()];
|
|
57
|
+
const span: SmithersElectricProxySpan = {
|
|
58
|
+
name: `smithers.${event.type}`,
|
|
59
|
+
attributes: {
|
|
60
|
+
"smithers.electric.principal_id": event.principalId,
|
|
61
|
+
"smithers.electric.table": event.table,
|
|
62
|
+
"smithers.electric.shape": event.shape,
|
|
63
|
+
"smithers.electric.reason": event.reason,
|
|
64
|
+
"smithers.electric.required_scope": event.requiredScope,
|
|
65
|
+
"smithers.electric.status": event.status,
|
|
66
|
+
"smithers.electric.duration_ms": event.durationMs,
|
|
67
|
+
"smithers.electric.forwarded_bytes": event.forwardedBytes,
|
|
68
|
+
"smithers.electric.lag_ms": event.lagMs,
|
|
69
|
+
"smithers.electric.txid": event.txid,
|
|
70
|
+
"smithers.electric.method": event.method,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
for (const sink of sinks) {
|
|
74
|
+
if (!sink) continue;
|
|
75
|
+
// Guard event and span independently: a throwing event sink must not
|
|
76
|
+
// suppress the span (and neither may break the Electric path).
|
|
77
|
+
try {
|
|
78
|
+
sink.event?.(event);
|
|
79
|
+
} catch {
|
|
80
|
+
// Observability must never break the Electric path.
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
sink.span?.(span);
|
|
84
|
+
} catch {
|
|
85
|
+
// Observability must never break the Electric path.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createSmithersElectricProxy,
|
|
3
|
+
type SmithersElectricAuthContext,
|
|
4
|
+
type SmithersElectricProxy,
|
|
5
|
+
type SmithersElectricProxyOptions,
|
|
6
|
+
type SmithersElectricScopeDecision,
|
|
7
|
+
} from "./createSmithersElectricProxy.ts";
|
|
8
|
+
export {
|
|
9
|
+
createSmithersElectricProxyMetrics,
|
|
10
|
+
type SmithersElectricProxyMetrics,
|
|
11
|
+
type SmithersElectricProxyMetricSnapshot,
|
|
12
|
+
} from "./createSmithersElectricProxyMetrics.ts";
|
|
13
|
+
export {
|
|
14
|
+
smithersElectricShapeCatalog,
|
|
15
|
+
smithersElectricCatalogWithOutputTables,
|
|
16
|
+
outputTableShape,
|
|
17
|
+
type SmithersElectricShapeDefinition,
|
|
18
|
+
} from "./smithersElectricShapeCatalog.ts";
|
|
19
|
+
export {
|
|
20
|
+
emitSmithersElectricEvent,
|
|
21
|
+
type SmithersElectricProxyObserver,
|
|
22
|
+
type SmithersElectricProxyEvent,
|
|
23
|
+
type SmithersElectricProxySpan,
|
|
24
|
+
} from "./createSmithersElectricProxyObserver.ts";
|
|
25
|
+
export {
|
|
26
|
+
serveSmithersElectricProxy,
|
|
27
|
+
type ServeSmithersElectricProxyOptions,
|
|
28
|
+
type SmithersElectricProxyServer,
|
|
29
|
+
} from "./serveSmithersElectricProxy.ts";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import type { SmithersElectricProxy } from "./createSmithersElectricProxy.ts";
|
|
3
|
+
|
|
4
|
+
export type ServeSmithersElectricProxyOptions = {
|
|
5
|
+
proxy: SmithersElectricProxy;
|
|
6
|
+
port?: number;
|
|
7
|
+
host?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SmithersElectricProxyServer = {
|
|
11
|
+
server: Server;
|
|
12
|
+
port: number;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function requestUrl(req: IncomingMessage): string {
|
|
17
|
+
const host = req.headers.host ?? "electric-proxy.local";
|
|
18
|
+
return `http://${host}${req.url ?? "/"}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toFetchRequest(req: IncomingMessage): Request {
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
24
|
+
if (value === undefined) continue;
|
|
25
|
+
if (Array.isArray(value)) for (const item of value) headers.append(key, item);
|
|
26
|
+
else headers.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
// Shape reads are GET/OPTIONS only; no body is forwarded.
|
|
29
|
+
return new Request(requestUrl(req), { method: req.method ?? "GET", headers });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeFetchResponse(res: ServerResponse, response: Response): Promise<void> {
|
|
33
|
+
const headers: Record<string, string> = {};
|
|
34
|
+
response.headers.forEach((value, key) => {
|
|
35
|
+
headers[key] = value;
|
|
36
|
+
});
|
|
37
|
+
res.writeHead(response.status, response.statusText, headers);
|
|
38
|
+
if (!response.body) {
|
|
39
|
+
res.end();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const reader = response.body.getReader();
|
|
43
|
+
try {
|
|
44
|
+
for (;;) {
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done) break;
|
|
47
|
+
if (value) res.write(Buffer.from(value));
|
|
48
|
+
}
|
|
49
|
+
res.end();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Abort the response so the client sees a truncated stream rather than a
|
|
52
|
+
// silently-complete one when Electric forwarding fails mid-stream.
|
|
53
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run the Smithers Electric proxy as a real Node HTTP server. This is the
|
|
59
|
+
* runnable cloud entry point that fronts `electricsql/electric` with auth,
|
|
60
|
+
* scope, grant-based where filling, rate limits, frame bounds, and
|
|
61
|
+
* metrics/spans (the `/metrics` and `/healthz` routes are served by the proxy).
|
|
62
|
+
*/
|
|
63
|
+
export function serveSmithersElectricProxy(
|
|
64
|
+
options: ServeSmithersElectricProxyOptions,
|
|
65
|
+
): Promise<SmithersElectricProxyServer> {
|
|
66
|
+
const { proxy } = options;
|
|
67
|
+
const server = createServer((req, res) => {
|
|
68
|
+
void proxy
|
|
69
|
+
.fetch(toFetchRequest(req))
|
|
70
|
+
.then((response) => writeFetchResponse(res, response))
|
|
71
|
+
.catch((error) => {
|
|
72
|
+
if (!res.headersSent) {
|
|
73
|
+
res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
|
74
|
+
}
|
|
75
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
server.listen(options.port ?? 0, options.host ?? "0.0.0.0", () => {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
const port = address && typeof address === "object" ? address.port : 0;
|
|
83
|
+
resolve({
|
|
84
|
+
server,
|
|
85
|
+
port,
|
|
86
|
+
close: () => new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { GatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
|
|
2
|
+
|
|
3
|
+
export type SmithersElectricShapeDefinition = {
|
|
4
|
+
name: string;
|
|
5
|
+
table: string;
|
|
6
|
+
requiredScope: GatewayScope;
|
|
7
|
+
whereTemplate?: string;
|
|
8
|
+
runIdColumn?: string;
|
|
9
|
+
workspaceIdColumn?: string;
|
|
10
|
+
userPrivateColumn?: string;
|
|
11
|
+
tablePattern?: RegExp;
|
|
12
|
+
description?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const smithersElectricShapeCatalog: readonly SmithersElectricShapeDefinition[] = [
|
|
16
|
+
{
|
|
17
|
+
name: "runs",
|
|
18
|
+
table: "_smithers_runs",
|
|
19
|
+
requiredScope: "run:read",
|
|
20
|
+
runIdColumn: "run_id",
|
|
21
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
22
|
+
description: "Run summaries and per-run records.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "nodes",
|
|
26
|
+
table: "_smithers_nodes",
|
|
27
|
+
requiredScope: "run:read",
|
|
28
|
+
runIdColumn: "run_id",
|
|
29
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
30
|
+
description: "Per-run node state.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "attempts",
|
|
34
|
+
table: "_smithers_attempts",
|
|
35
|
+
requiredScope: "run:read",
|
|
36
|
+
runIdColumn: "run_id",
|
|
37
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
38
|
+
description: "Per-run attempt rows.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "events",
|
|
42
|
+
table: "_smithers_events",
|
|
43
|
+
requiredScope: "run:read",
|
|
44
|
+
runIdColumn: "run_id",
|
|
45
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
46
|
+
description: "Per-run event log rows.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "approvals",
|
|
50
|
+
table: "_smithers_approvals",
|
|
51
|
+
requiredScope: "run:read",
|
|
52
|
+
runIdColumn: "run_id",
|
|
53
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
54
|
+
description: "Human approval requests and decisions.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "node_diffs",
|
|
58
|
+
table: "_smithers_node_diffs",
|
|
59
|
+
requiredScope: "run:read",
|
|
60
|
+
runIdColumn: "run_id",
|
|
61
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
62
|
+
description: "Cached node DiffBundle rows.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "docs",
|
|
66
|
+
table: "_smithers_docs",
|
|
67
|
+
requiredScope: "run:read",
|
|
68
|
+
description: "DB-backed tickets, plans, specs, and proposals.",
|
|
69
|
+
},
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a run-scoped shape entry for one workflow output table. Output tables
|
|
74
|
+
* are NOT a regex catch-all (that would expose any identifier-named table that
|
|
75
|
+
* happens to carry a `run_id` column); the proxy must be handed the explicit
|
|
76
|
+
* allowlist of real output-table names — derived from the output-table
|
|
77
|
+
* registry — so only enumerated tables are reachable, each still scoped by
|
|
78
|
+
* `run_id IN ({run_ids})`.
|
|
79
|
+
*/
|
|
80
|
+
export function outputTableShape(table: string): SmithersElectricShapeDefinition {
|
|
81
|
+
return {
|
|
82
|
+
name: `output:${table}`,
|
|
83
|
+
table,
|
|
84
|
+
requiredScope: "run:read",
|
|
85
|
+
runIdColumn: "run_id",
|
|
86
|
+
whereTemplate: "run_id IN ({run_ids})",
|
|
87
|
+
description: `Workflow output table ${table}, scoped by run_id.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compose the base `_smithers_*` shape catalog with explicit per-table entries
|
|
93
|
+
* for the supplied output-table allowlist. Passing `[]` (the default) means no
|
|
94
|
+
* output table is reachable at all.
|
|
95
|
+
*/
|
|
96
|
+
export function smithersElectricCatalogWithOutputTables(
|
|
97
|
+
outputTables: readonly string[],
|
|
98
|
+
): readonly SmithersElectricShapeDefinition[] {
|
|
99
|
+
if (outputTables.length === 0) return smithersElectricShapeCatalog;
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
const extra: SmithersElectricShapeDefinition[] = [];
|
|
102
|
+
for (const table of outputTables) {
|
|
103
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table) || seen.has(table)) continue;
|
|
104
|
+
seen.add(table);
|
|
105
|
+
extra.push(outputTableShape(table));
|
|
106
|
+
}
|
|
107
|
+
return [...smithersElectricShapeCatalog, ...extra];
|
|
108
|
+
}
|