@electric-ax/agents-server 0.4.18 → 0.4.20

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.
@@ -1,6 +1,7 @@
1
1
  import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
2
2
  import { apiError } from '../electric-agents-http.js'
3
3
  import { ElectricAgentsError } from '../entity-manager.js'
4
+ import { ElectricProxyError } from '../utils/server-utils.js'
4
5
  import { ELECTRIC_PRINCIPAL_HEADER } from '../principal.js'
5
6
  import { ATTR, extractTraceContext, tracer } from '../tracing.js'
6
7
  import { serverLog } from '../utils/log.js'
@@ -112,6 +113,12 @@ export function errorMapper(err: unknown, req: IRequest): Response {
112
113
  if (err instanceof ElectricAgentsError) {
113
114
  return apiError(err.status, err.code, err.message, err.details)
114
115
  }
116
+ if (err instanceof ElectricProxyError) {
117
+ serverLog.warn(
118
+ `[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`
119
+ )
120
+ return apiError(err.status, err.code, err.message)
121
+ }
115
122
  serverLog.error(`[agent-server] Unhandled error:`, err)
116
123
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`)
117
124
  }
@@ -30,6 +30,7 @@ import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-a
30
30
  import { electricProxyRouter } from './electric-proxy-router.js'
31
31
  import { entitiesRouter } from './entities-router.js'
32
32
  import { entityTypesRouter } from './entity-types-router.js'
33
+ import { pgSyncRouter } from './pg-sync-router.js'
33
34
  import { getRequestSpan } from './hooks.js'
34
35
  import { observationsRouter } from './observations-router.js'
35
36
  import { runnersRouter } from './runners-router.js'
@@ -135,6 +136,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch)
135
136
  internalRouter.all(`/runners/*`, runnersRouter.fetch)
136
137
  internalRouter.all(`/entities/*`, entitiesRouter.fetch)
137
138
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
139
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch)
138
140
  internalRouter.all(`/observations/*`, observationsRouter.fetch)
139
141
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
140
142
  internalRouter.all(`*`, () => status(404))
@@ -0,0 +1,113 @@
1
+ /**
2
+ * HTTP routes for pg-sync observation source registration.
3
+ */
4
+
5
+ import type {
6
+ PgSyncOptions,
7
+ PgSyncRequestMetadata,
8
+ } from '@electric-ax/agents-runtime'
9
+ import { Type, type Static } from '@sinclair/typebox'
10
+ import { Router, json } from 'itty-router'
11
+ import { apiError } from '../electric-agents-http.js'
12
+ import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
13
+ import { routeBody, withSchema } from './schema.js'
14
+ import type { JsonRouteRequest } from './schema.js'
15
+ import type { RouterType } from 'itty-router'
16
+ import type { TenantContext } from './context.js'
17
+
18
+ const pgSyncOptionsSchema = Type.Object({
19
+ url: Type.Optional(Type.String()),
20
+ table: Type.String(),
21
+ columns: Type.Optional(Type.Array(Type.String())),
22
+ where: Type.Optional(Type.String()),
23
+ params: Type.Optional(
24
+ Type.Union([
25
+ Type.Array(Type.String()),
26
+ Type.Record(Type.String(), Type.String()),
27
+ ])
28
+ ),
29
+ replica: Type.Optional(
30
+ Type.Union([Type.Literal(`default`), Type.Literal(`full`)])
31
+ ),
32
+ })
33
+
34
+ const pgSyncRequestMetadataSchema = Type.Object({
35
+ entityUrl: Type.Optional(Type.String()),
36
+ entityType: Type.Optional(Type.String()),
37
+ streamPath: Type.Optional(Type.String()),
38
+ runtimeConsumerId: Type.Optional(Type.String()),
39
+ wakeId: Type.Optional(Type.String()),
40
+ })
41
+
42
+ const pgSyncRegisterBodySchema = Type.Object({
43
+ options: pgSyncOptionsSchema,
44
+ metadata: Type.Optional(pgSyncRequestMetadataSchema),
45
+ })
46
+
47
+ type PgSyncRegisterBody = Static<typeof pgSyncRegisterBodySchema>
48
+
49
+ export type PgSyncRoutes = RouterType<
50
+ JsonRouteRequest,
51
+ [TenantContext],
52
+ Response | undefined
53
+ >
54
+
55
+ export const pgSyncRouter: PgSyncRoutes = Router<
56
+ JsonRouteRequest,
57
+ [TenantContext],
58
+ Response | undefined
59
+ >({
60
+ base: `/_electric/pg-sync`,
61
+ })
62
+
63
+ pgSyncRouter.post(
64
+ `/register`,
65
+ withSchema(pgSyncRegisterBodySchema),
66
+ registerPgSync
67
+ )
68
+
69
+ async function registerPgSync(
70
+ request: JsonRouteRequest,
71
+ ctx: TenantContext
72
+ ): Promise<Response> {
73
+ const { options, metadata } = routeBody<PgSyncRegisterBody>(request)
74
+
75
+ if (options.table.trim() === ``) {
76
+ return apiError(
77
+ 400,
78
+ ErrCodeInvalidRequest,
79
+ `pgSync table must be non-empty`
80
+ )
81
+ }
82
+
83
+ if (!ctx.pgSyncBridgeManager) {
84
+ return apiError(
85
+ 503,
86
+ ErrCodeInvalidRequest,
87
+ `pgSync bridge manager is not configured`
88
+ )
89
+ }
90
+
91
+ try {
92
+ const requestMetadata: PgSyncRequestMetadata = {
93
+ tenantId: ctx.service,
94
+ principalKind: ctx.principal.kind,
95
+ principalId: ctx.principal.id,
96
+ principalKey: ctx.principal.key,
97
+ principalUrl: ctx.principal.url,
98
+ ...(metadata ?? {}),
99
+ }
100
+ const result = await ctx.pgSyncBridgeManager.register(
101
+ options as PgSyncOptions,
102
+ requestMetadata
103
+ )
104
+
105
+ return json(result)
106
+ } catch (error) {
107
+ return apiError(
108
+ 500,
109
+ ErrCodeInvalidRequest,
110
+ `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`
111
+ )
112
+ }
113
+ }
package/src/runtime.ts CHANGED
@@ -14,7 +14,12 @@ import { isPermanentElectricAgentsError } from './scheduler.js'
14
14
  import { StreamClient } from './stream-client.js'
15
15
  import { DEFAULT_TENANT_ID } from './tenant.js'
16
16
  import type { DrizzleDB } from './db/index.js'
17
+ import { PgSyncBridgeManager } from './pg-sync-bridge-manager.js'
17
18
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
19
+ import type {
20
+ PgSyncBridgeCoordinator,
21
+ PgSyncBridgeManagerOptions,
22
+ } from './pg-sync-bridge-manager.js'
18
23
  import type { DurableStreamsBearerProvider } from './stream-client.js'
19
24
  import type {
20
25
  CronTickPayload,
@@ -40,6 +45,8 @@ export interface ElectricAgentsTenantRuntimeOptions {
40
45
  wakeRegistry: WakeRegistry
41
46
  scheduler: SchedulerClient
42
47
  entityBridgeManager: EntityBridgeCoordinator
48
+ pgSyncBridgeManager?: PgSyncBridgeCoordinator
49
+ pgSync?: PgSyncBridgeManagerOptions
43
50
  claimWriteTokens?: ClaimWriteTokenStore
44
51
  stopWakeRegistryOnShutdown?: boolean
45
52
  }
@@ -53,6 +60,7 @@ export class ElectricAgentsTenantRuntime {
53
60
  readonly wakeRegistry: WakeRegistry
54
61
  readonly scheduler: SchedulerClient
55
62
  readonly entityBridgeManager: EntityBridgeCoordinator
63
+ readonly pgSyncBridgeManager: PgSyncBridgeCoordinator
56
64
  readonly claimWriteTokens: ClaimWriteTokenStore
57
65
  readonly manager: EntityManager
58
66
 
@@ -92,10 +100,21 @@ export class ElectricAgentsTenantRuntime {
92
100
  ),
93
101
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false,
94
102
  })
103
+ this.pgSyncBridgeManager =
104
+ options.pgSyncBridgeManager ??
105
+ new PgSyncBridgeManager(
106
+ this.streamClient,
107
+ (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event),
108
+ this.registry,
109
+ options.pgSync
110
+ )
95
111
  }
96
112
 
97
113
  async stop(): Promise<void> {
98
- await this.manager.shutdown()
114
+ await Promise.all([
115
+ this.manager.shutdown(),
116
+ this.pgSyncBridgeManager.stop(),
117
+ ])
99
118
  }
100
119
 
101
120
  async rehydrateCronSchedules(): Promise<void> {
package/src/scheduler.ts CHANGED
@@ -228,6 +228,14 @@ export function isPermanentElectricAgentsError(err: unknown): boolean {
228
228
  )
229
229
  }
230
230
 
231
+ function cronTaskStreamPath(
232
+ payload: DelayedSendPayload | CronTickPayload
233
+ ): string | null {
234
+ return typeof (payload as { streamPath?: unknown }).streamPath === `string`
235
+ ? (payload as { streamPath: string }).streamPath
236
+ : null
237
+ }
238
+
231
239
  function normalizeTask(row: ScheduledTaskRow): {
232
240
  id: number
233
241
  tenantId: string
@@ -680,6 +688,24 @@ export class Scheduler implements SchedulerClient {
680
688
  task.fireAt
681
689
  )
682
690
 
691
+ const streamPath = cronTaskStreamPath(task.payload)
692
+ const subscriberRows = streamPath
693
+ ? await sql<Array<{ exists: number }>>`
694
+ select 1 as exists
695
+ from wake_registrations
696
+ where tenant_id = ${tenantId}
697
+ and source_url = ${streamPath}
698
+ limit 1
699
+ `
700
+ : []
701
+
702
+ // Cron streams are virtual shared sources. If no wake registrations
703
+ // still point at this cron stream (e.g. the owning manifest schedule was
704
+ // deleted), stop the chain here instead of keeping a forever-global tick
705
+ // alive. Rehydration/getOrCreateCronStream will seed a fresh tick when a
706
+ // subscription is recreated.
707
+ if (subscriberRows.length === 0) return
708
+
683
709
  await sql`
684
710
  insert into scheduled_tasks (
685
711
  tenant_id,
package/src/server.ts CHANGED
@@ -36,6 +36,7 @@ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
36
36
  import type { DurableStreamsRoutingAdapter } from './routing/durable-streams-routing-adapter.js'
37
37
  import type { OssServerContext } from './routing/oss-server-router.js'
38
38
  import type { EventSourceCatalog } from './routing/context.js'
39
+ import type { PgSyncBridgeManagerOptions } from './pg-sync-bridge-manager.js'
39
40
  import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
40
41
  import type { DurableStreamsBearerProvider } from './stream-client.js'
41
42
  import type {
@@ -72,6 +73,7 @@ export interface ElectricAgentsServerOptions {
72
73
  allowDevPrincipalFallback?: boolean
73
74
  eventSources?: EventSourceCatalog
74
75
  ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
76
+ pgSync?: PgSyncBridgeManagerOptions
75
77
  /**
76
78
  * Disabled by default. When set to a positive interval, periodically
77
79
  * recovers expired dispatch claims and stale outstanding wakes.
@@ -242,6 +244,7 @@ export class ElectricAgentsServer {
242
244
  streamClient: this.streamClient,
243
245
  electricUrl: this.options.electricUrl,
244
246
  electricSecret: this.options.electricSecret,
247
+ pgSync: this.options.pgSync,
245
248
  })
246
249
  this.electricAgentsManager = this.standaloneRuntime.manager
247
250
  this.entityBridgeManager = this.standaloneRuntime.entityBridgeManager
@@ -446,6 +449,7 @@ export class ElectricAgentsServer {
446
449
  streamClient: this.streamClient,
447
450
  runtime: this.standaloneRuntime.runtime,
448
451
  entityBridgeManager: this.entityBridgeManager,
452
+ pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
449
453
  ...(this.options.eventSources
450
454
  ? { eventSources: this.options.eventSources }
451
455
  : {}),
@@ -11,6 +11,10 @@ import { WakeRegistry } from './wake-registry.js'
11
11
  import type { DrizzleDB, PgClient } from './db/index.js'
12
12
  import type { EntityManager } from './entity-manager.js'
13
13
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
14
+ import type {
15
+ PgSyncBridgeCoordinator,
16
+ PgSyncBridgeManagerOptions,
17
+ } from './pg-sync-bridge-manager.js'
14
18
  import type { CronTickPayload, DelayedSendPayload } from './scheduler.js'
15
19
  import type { DurableStreamsBearerProvider } from './stream-client.js'
16
20
 
@@ -30,8 +34,11 @@ export interface StandaloneAgentsRuntimeOptions {
30
34
  startScheduler?: boolean
31
35
  startTagStreamOutboxDrainer?: boolean
32
36
  startEntityBridgeManager?: boolean
37
+ startPgSyncBridgeManager?: boolean
33
38
  rehydrateOnStart?: boolean
34
39
  entityBridgeManager?: EntityBridgeCoordinator
40
+ pgSyncBridgeManager?: PgSyncBridgeCoordinator
41
+ pgSync?: PgSyncBridgeManagerOptions
35
42
  }
36
43
 
37
44
  export interface StartedStandaloneAgentsRuntime {
@@ -46,6 +53,7 @@ export interface StartedStandaloneAgentsRuntime {
46
53
  manager: EntityManager
47
54
  scheduler: Scheduler
48
55
  entityBridgeManager: EntityBridgeCoordinator
56
+ pgSyncBridgeManager: PgSyncBridgeCoordinator
49
57
  tagStreamOutboxDrainer: TagStreamOutboxDrainer
50
58
  stop: () => Promise<void>
51
59
  }
@@ -104,6 +112,8 @@ export async function startStandaloneAgentsRuntime(
104
112
  wakeRegistry,
105
113
  scheduler,
106
114
  entityBridgeManager,
115
+ pgSyncBridgeManager: options.pgSyncBridgeManager,
116
+ pgSync: options.pgSync,
107
117
  stopWakeRegistryOnShutdown: options.wakeRegistry ? false : true,
108
118
  })
109
119
 
@@ -112,6 +122,7 @@ export async function startStandaloneAgentsRuntime(
112
122
  const startTagStreamOutboxDrainer =
113
123
  options.startTagStreamOutboxDrainer ?? true
114
124
  const startEntityBridgeManager = options.startEntityBridgeManager ?? true
125
+ const startPgSyncBridgeManager = options.startPgSyncBridgeManager ?? true
115
126
  const rehydrateOnStart = options.rehydrateOnStart ?? true
116
127
  let entityBridgeManagerStarted = false
117
128
  let tagStreamOutboxDrainerStarted = false
@@ -153,6 +164,10 @@ export async function startStandaloneAgentsRuntime(
153
164
  await entityBridgeManager.start()
154
165
  entityBridgeManagerStarted = true
155
166
  }
167
+ if (startPgSyncBridgeManager) {
168
+ serverLog.info(`[agent-server] starting pg-sync bridge manager...`)
169
+ await runtime.pgSyncBridgeManager.start?.()
170
+ }
156
171
  if (startTagStreamOutboxDrainer) {
157
172
  serverLog.info(`[agent-server] starting tag stream outbox drainer...`)
158
173
  tagStreamOutboxDrainer.start()
@@ -181,6 +196,7 @@ export async function startStandaloneAgentsRuntime(
181
196
  manager: runtime.manager,
182
197
  scheduler,
183
198
  entityBridgeManager,
199
+ pgSyncBridgeManager: runtime.pgSyncBridgeManager,
184
200
  tagStreamOutboxDrainer,
185
201
  stop,
186
202
  }
@@ -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