@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.
@@ -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
- const table = options.incomingUrl.searchParams.get(`table`)
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
@@ -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
- this.removeCachedRegistrationByDbId(Number(message.key))
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