@electric-ax/agents-server 0.4.19 → 0.5.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/dist/entrypoint.js +692 -45
- package/dist/index.cjs +678 -41
- package/dist/index.d.cts +2519 -2216
- package/dist/index.d.ts +2518 -2217
- package/dist/index.js +679 -42
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +32 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +160 -29
- package/src/entity-registry.ts +158 -3
- package/src/manifest-side-effects.ts +10 -0
- package/src/pg-sync-bridge-manager.ts +552 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/entities-router.ts +89 -18
- package/src/routing/entity-types-router.ts +56 -0
- package/src/routing/global-router.ts +3 -0
- package/src/routing/hooks.ts +7 -0
- package/src/routing/internal-router.ts +2 -0
- package/src/routing/pg-sync-router.ts +113 -0
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +26 -0
- package/src/server.ts +4 -0
- package/src/standalone-runtime.ts +16 -0
- package/src/utils/server-utils.ts +97 -1
- package/src/wake-registry.ts +27 -2
|
@@ -90,6 +90,25 @@ export async function fileExists(filePath: string): Promise<boolean> {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
95
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
96
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
97
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
98
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
99
|
+
* import graph.
|
|
100
|
+
*/
|
|
101
|
+
export class ElectricProxyError extends Error {
|
|
102
|
+
constructor(
|
|
103
|
+
readonly code: `INVALID_WHERE` | `TABLE_NOT_ALLOWED`,
|
|
104
|
+
message: string,
|
|
105
|
+
readonly status: number
|
|
106
|
+
) {
|
|
107
|
+
super(message)
|
|
108
|
+
this.name = `ElectricProxyError`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
export function buildElectricProxyTarget(options: {
|
|
94
113
|
incomingUrl: URL
|
|
95
114
|
electricUrl: string
|
|
@@ -117,7 +136,29 @@ export function buildElectricProxyTarget(options: {
|
|
|
117
136
|
target.searchParams.set(`secret`, options.electricSecret)
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
|
|
139
|
+
// The enforced scoping `where` is AND-combined with the client's own `where`.
|
|
140
|
+
// A client clause that is not self-contained (e.g. `1=1) OR (1=1`) could
|
|
141
|
+
// break out of its parenthesised group and neutralise the scoping under SQL
|
|
142
|
+
// operator precedence, so reject anything that isn't balanced.
|
|
143
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`)
|
|
144
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) {
|
|
145
|
+
throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`)
|
|
149
|
+
if (tableParams.length !== 1) {
|
|
150
|
+
throw new ElectricProxyError(
|
|
151
|
+
`TABLE_NOT_ALLOWED`,
|
|
152
|
+
`Table is not available through the Electric proxy`,
|
|
153
|
+
403
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const table = tableParams[0]
|
|
158
|
+
// Canonicalise the upstream table after validation so duplicate client query
|
|
159
|
+
// params cannot be interpreted differently by Electric or intermediaries.
|
|
160
|
+
target.searchParams.set(`table`, table)
|
|
161
|
+
|
|
121
162
|
if (table === `entities`) {
|
|
122
163
|
target.searchParams.set(
|
|
123
164
|
`columns`,
|
|
@@ -224,11 +265,66 @@ export function buildElectricProxyTarget(options: {
|
|
|
224
265
|
permissionBypass: options.permissionBypass,
|
|
225
266
|
})
|
|
226
267
|
)
|
|
268
|
+
} else {
|
|
269
|
+
// Default-deny: every shape request gets the privileged Electric secret
|
|
270
|
+
// (when configured) injected above, so only tables with explicit column +
|
|
271
|
+
// row scoping may be proxied. Any other table (or a missing `table` param)
|
|
272
|
+
// is rejected.
|
|
273
|
+
throw new ElectricProxyError(
|
|
274
|
+
`TABLE_NOT_ALLOWED`,
|
|
275
|
+
`Table is not available through the Electric proxy`,
|
|
276
|
+
403
|
|
277
|
+
)
|
|
227
278
|
}
|
|
228
279
|
|
|
229
280
|
return target
|
|
230
281
|
}
|
|
231
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
285
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
286
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
287
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
288
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
289
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
290
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
291
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
292
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
293
|
+
*/
|
|
294
|
+
function isSelfContainedWhereClause(where: string): boolean {
|
|
295
|
+
let depth = 0
|
|
296
|
+
let quote: `'` | `"` | null = null
|
|
297
|
+
for (let i = 0; i < where.length; i++) {
|
|
298
|
+
const ch = where[i]
|
|
299
|
+
if (quote !== null) {
|
|
300
|
+
if (ch === quote) {
|
|
301
|
+
if (where[i + 1] === quote) {
|
|
302
|
+
i++ // doubled quote is an escaped literal quote
|
|
303
|
+
} else {
|
|
304
|
+
quote = null
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if (ch === `'` || ch === `"`) {
|
|
310
|
+
quote = ch
|
|
311
|
+
} else if (ch === `(`) {
|
|
312
|
+
depth++
|
|
313
|
+
} else if (ch === `)`) {
|
|
314
|
+
depth--
|
|
315
|
+
if (depth < 0) {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
} else if (
|
|
319
|
+
(ch === `-` && where[i + 1] === `-`) ||
|
|
320
|
+
(ch === `/` && where[i + 1] === `*`)
|
|
321
|
+
) {
|
|
322
|
+
return false // SQL comment marker
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return depth === 0 && quote === null
|
|
326
|
+
}
|
|
327
|
+
|
|
232
328
|
export function buildReadableEntitiesWhere(options: {
|
|
233
329
|
tenantId: string
|
|
234
330
|
principalUrl: string
|
package/src/wake-registry.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface WakeEvalResult {
|
|
|
41
41
|
collection: string
|
|
42
42
|
kind: `insert` | `update` | `delete`
|
|
43
43
|
key: string
|
|
44
|
+
value?: unknown
|
|
45
|
+
oldValue?: unknown
|
|
44
46
|
from?: string
|
|
45
47
|
from_principal?: string
|
|
46
48
|
from_agent?: string
|
|
@@ -737,7 +739,23 @@ export class WakeRegistry {
|
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
if (message.headers.operation === `delete`) {
|
|
740
|
-
|
|
742
|
+
// Shape keys are protocol-level identifiers and are not guaranteed to be
|
|
743
|
+
// the table primary key. The wake_registrations shape uses
|
|
744
|
+
// `replica: full`, so deletes should carry the deleted row in old_value;
|
|
745
|
+
// use that row id to remove the matching in-memory registration. If the
|
|
746
|
+
// id is unavailable, reset the cache so we fail closed rather than
|
|
747
|
+
// keeping a stale wake registration alive.
|
|
748
|
+
const oldValue = (
|
|
749
|
+
message as unknown as {
|
|
750
|
+
old_value?: { id?: unknown }
|
|
751
|
+
}
|
|
752
|
+
).old_value
|
|
753
|
+
const oldId = Number(oldValue?.id)
|
|
754
|
+
if (Number.isFinite(oldId)) {
|
|
755
|
+
this.removeCachedRegistrationByDbId(oldId)
|
|
756
|
+
} else {
|
|
757
|
+
this.resetCachedRegistrations()
|
|
758
|
+
}
|
|
741
759
|
return
|
|
742
760
|
}
|
|
743
761
|
|
|
@@ -937,14 +955,21 @@ export class WakeRegistry {
|
|
|
937
955
|
return null
|
|
938
956
|
}
|
|
939
957
|
|
|
958
|
+
const value = event.value as Record<string, unknown> | undefined
|
|
940
959
|
const change: WakeEvalResult[`wakeMessage`][`changes`][number] = {
|
|
941
960
|
collection: eventType,
|
|
942
961
|
kind,
|
|
943
962
|
key: (event.key as string) || ``,
|
|
944
963
|
}
|
|
945
964
|
|
|
965
|
+
if (value && `value` in value) {
|
|
966
|
+
change.value = value.value
|
|
967
|
+
}
|
|
968
|
+
if (value && `oldValue` in value) {
|
|
969
|
+
change.oldValue = value.oldValue
|
|
970
|
+
}
|
|
971
|
+
|
|
946
972
|
if (eventType === `inbox`) {
|
|
947
|
-
const value = event.value as Record<string, unknown> | undefined
|
|
948
973
|
if (typeof value?.from === `string`) change.from = value.from
|
|
949
974
|
if (typeof value?.from_principal === `string`) {
|
|
950
975
|
change.from_principal = value.from_principal
|