@electric-ax/agents-server 0.4.14 → 0.4.16

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/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import { apiError } from './electric-agents-http.js'
16
16
  import {
17
17
  ErrCodeInvalidRequest,
18
18
  ErrCodeUnauthorized,
19
+ type AuthorizeRequest,
19
20
  } from './electric-agents-types.js'
20
21
  import { ElectricAgentsError } from './entity-manager.js'
21
22
  import { serverLog } from './utils/log.js'
@@ -67,6 +68,7 @@ export interface ElectricAgentsServerOptions {
67
68
  authenticateRequest?: (
68
69
  request: Request
69
70
  ) => Promise<Principal | null> | Principal | null
71
+ authorizeRequest?: AuthorizeRequest
70
72
  allowDevPrincipalFallback?: boolean
71
73
  eventSources?: EventSourceCatalog
72
74
  ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
@@ -453,6 +455,9 @@ export class ElectricAgentsServer {
453
455
  this.options.ensureEventSourceWakeSource,
454
456
  }
455
457
  : {}),
458
+ ...(this.options.authorizeRequest
459
+ ? { authorizeRequest: this.options.authorizeRequest }
460
+ : {}),
456
461
  isShuttingDown: () => this.shuttingDown,
457
462
  mockAgent: this.mockAgentBootstrap
458
463
  ? { runtime: this.mockAgentBootstrap.runtime }
@@ -4,6 +4,7 @@ import {
4
4
  FetchError,
5
5
  IdempotentProducer,
6
6
  } from '@durable-streams/client'
7
+ import type { EventPointer } from '@electric-ax/agents-runtime'
7
8
  import { ErrCodeNotFound } from './electric-agents-types.js'
8
9
  import { ATTR, injectTraceHeaders, withSpan } from './tracing.js'
9
10
  import type { HeadersRecord, MaybePromise } from '@durable-streams/client'
@@ -242,7 +243,11 @@ export class StreamClient {
242
243
  })
243
244
  }
244
245
 
245
- async fork(path: string, sourcePath: string): Promise<void> {
246
+ async fork(
247
+ path: string,
248
+ sourcePath: string,
249
+ opts?: { forkPointer?: EventPointer }
250
+ ): Promise<void> {
246
251
  return await withSpan(`stream.fork`, async (span) => {
247
252
  span.setAttributes({
248
253
  [ATTR.STREAM_PATH]: path,
@@ -252,6 +257,17 @@ export class StreamClient {
252
257
  'content-type': `application/json`,
253
258
  'Stream-Forked-From': new URL(this.streamUrl(sourcePath)).pathname,
254
259
  }
260
+ if (opts?.forkPointer) {
261
+ // The durable-streams server returns 400 if Stream-Fork-Sub-Offset
262
+ // > 0 without an accompanying Stream-Fork-Offset. When our
263
+ // pointer's offset is `null` (anchor at stream start), send the
264
+ // explicit zero-offset string to satisfy that constraint.
265
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`
266
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET
267
+ if (opts.forkPointer.subOffset > 0) {
268
+ headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset)
269
+ }
270
+ }
255
271
  injectTraceHeaders(headers)
256
272
 
257
273
  const response = await fetch(this.streamUrl(path), {
@@ -96,6 +96,8 @@ export function buildElectricProxyTarget(options: {
96
96
  electricSecret?: string
97
97
  tenantId: string
98
98
  principalUrl?: string
99
+ principalKind?: string
100
+ permissionBypass?: boolean
99
101
  }): URL {
100
102
  const targetPath = options.incomingUrl.pathname.replace(
101
103
  `/_electric/electric`,
@@ -119,23 +121,59 @@ export function buildElectricProxyTarget(options: {
119
121
  if (table === `entities`) {
120
122
  target.searchParams.set(
121
123
  `columns`,
122
- `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
124
+ `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
125
+ )
126
+ applyShapeWhere(
127
+ target,
128
+ buildReadableEntitiesWhere({
129
+ tenantId: options.tenantId,
130
+ principalUrl: options.principalUrl ?? ``,
131
+ principalKind: options.principalKind ?? ``,
132
+ permissionBypass: options.permissionBypass,
133
+ })
123
134
  )
124
- applyTenantShapeWhere(target, options.tenantId)
125
135
  } else if (table === `entity_types`) {
126
136
  target.searchParams.set(
127
137
  `columns`,
128
138
  `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
129
139
  )
130
- applyTenantShapeWhere(target, options.tenantId)
140
+ applyShapeWhere(
141
+ target,
142
+ buildSpawnableEntityTypesWhere({
143
+ tenantId: options.tenantId,
144
+ principalUrl: options.principalUrl ?? ``,
145
+ principalKind: options.principalKind ?? ``,
146
+ permissionBypass: options.permissionBypass,
147
+ })
148
+ )
131
149
  } else if (table === `runners`) {
132
150
  target.searchParams.set(
133
151
  `columns`,
134
- `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`
152
+ `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`
135
153
  )
136
154
  applyTenantShapeWhere(target, options.tenantId, [
137
155
  `owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`,
138
156
  ])
157
+ } else if (table === `users`) {
158
+ target.searchParams.set(
159
+ `columns`,
160
+ `"tenant_id","id","display_name","email","avatar_url","created_at","updated_at"`
161
+ )
162
+ applyTenantShapeWhere(target, options.tenantId)
163
+ } else if (table === `entity_effective_permissions`) {
164
+ target.searchParams.set(
165
+ `columns`,
166
+ `"tenant_id","id","entity_url","source_entity_url","source_grant_id","permission","subject_kind","subject_value","expires_at","created_at"`
167
+ )
168
+ applyShapeWhere(
169
+ target,
170
+ buildCurrentPrincipalEntityEffectivePermissionsWhere({
171
+ tenantId: options.tenantId,
172
+ principalUrl: options.principalUrl ?? ``,
173
+ principalKind: options.principalKind ?? ``,
174
+ permissionBypass: options.permissionBypass,
175
+ })
176
+ )
139
177
  } else if (table === `runner_runtime_diagnostics`) {
140
178
  target.searchParams.set(
141
179
  `columns`,
@@ -149,24 +187,154 @@ export function buildElectricProxyTarget(options: {
149
187
  `columns`,
150
188
  `"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`
151
189
  )
152
- applyTenantShapeWhere(target, options.tenantId)
190
+ applyShapeWhere(
191
+ target,
192
+ buildReadableEntityUrlWhere({
193
+ tenantId: options.tenantId,
194
+ principalUrl: options.principalUrl ?? ``,
195
+ principalKind: options.principalKind ?? ``,
196
+ permissionBypass: options.permissionBypass,
197
+ })
198
+ )
153
199
  } else if (table === `wake_notifications`) {
154
200
  target.searchParams.set(
155
201
  `columns`,
156
202
  `"tenant_id","wake_id","entity_url","target_type","target_runner_id","target_webhook_url","target_worker_pool_id","runner_wake_stream","runner_wake_stream_offset","notification_public","delivery_status","claim_status","created_at","delivered_at","claimed_at","resolved_at"`
157
203
  )
158
- applyTenantShapeWhere(target, options.tenantId)
204
+ applyShapeWhere(
205
+ target,
206
+ buildReadableEntityUrlWhere({
207
+ tenantId: options.tenantId,
208
+ principalUrl: options.principalUrl ?? ``,
209
+ principalKind: options.principalKind ?? ``,
210
+ permissionBypass: options.permissionBypass,
211
+ })
212
+ )
159
213
  } else if (table === `consumer_claims`) {
160
214
  target.searchParams.set(
161
215
  `columns`,
162
216
  `"tenant_id","consumer_id","epoch","wake_id","entity_url","stream_path","runner_id","status","claimed_at","last_heartbeat_at","lease_expires_at","released_at","acked_streams","updated_at"`
163
217
  )
164
- applyTenantShapeWhere(target, options.tenantId)
218
+ applyShapeWhere(
219
+ target,
220
+ buildReadableEntityUrlWhere({
221
+ tenantId: options.tenantId,
222
+ principalUrl: options.principalUrl ?? ``,
223
+ principalKind: options.principalKind ?? ``,
224
+ permissionBypass: options.permissionBypass,
225
+ })
226
+ )
165
227
  }
166
228
 
167
229
  return target
168
230
  }
169
231
 
232
+ export function buildReadableEntitiesWhere(options: {
233
+ tenantId: string
234
+ principalUrl: string
235
+ principalKind: string
236
+ permissionBypass?: boolean
237
+ }): string {
238
+ const tenant = sqlStringLiteral(options.tenantId)
239
+ if (options.permissionBypass) {
240
+ return `tenant_id = ${tenant}`
241
+ }
242
+ const principalUrl = sqlStringLiteral(options.principalUrl)
243
+ const principalKind = sqlStringLiteral(options.principalKind)
244
+ return [
245
+ `tenant_id = ${tenant}`,
246
+ `AND (`,
247
+ ` created_by = ${principalUrl}`,
248
+ ` OR url IN (`,
249
+ ` SELECT entity_url`,
250
+ ` FROM entity_effective_permissions`,
251
+ ` WHERE tenant_id = ${tenant}`,
252
+ ` AND permission IN ('read', 'manage')`,
253
+ ` AND (`,
254
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl})`,
255
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
256
+ ` )`,
257
+ ` )`,
258
+ `)`,
259
+ ].join(`\n`)
260
+ }
261
+
262
+ export function buildReadableEntityUrlWhere(options: {
263
+ tenantId: string
264
+ principalUrl: string
265
+ principalKind: string
266
+ permissionBypass?: boolean
267
+ }): string {
268
+ const tenant = sqlStringLiteral(options.tenantId)
269
+ if (options.permissionBypass) {
270
+ return `tenant_id = ${tenant}`
271
+ }
272
+ return [
273
+ `tenant_id = ${tenant}`,
274
+ `AND entity_url IN (`,
275
+ ` SELECT url`,
276
+ ` FROM entities`,
277
+ ` WHERE ${indentWhere(
278
+ buildReadableEntitiesWhere(options),
279
+ ` `
280
+ ).trimStart()}`,
281
+ `)`,
282
+ ].join(`\n`)
283
+ }
284
+
285
+ export function buildCurrentPrincipalEntityEffectivePermissionsWhere(options: {
286
+ tenantId: string
287
+ principalUrl: string
288
+ principalKind: string
289
+ permissionBypass?: boolean
290
+ }): string {
291
+ const tenant = sqlStringLiteral(options.tenantId)
292
+ if (options.permissionBypass) {
293
+ return `tenant_id = ${tenant}`
294
+ }
295
+ const principalUrl = sqlStringLiteral(options.principalUrl)
296
+ const principalKind = sqlStringLiteral(options.principalKind)
297
+ return [
298
+ `tenant_id = ${tenant}`,
299
+ `AND (`,
300
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl})`,
301
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
302
+ `)`,
303
+ `AND entity_url IN (`,
304
+ ` SELECT url`,
305
+ ` FROM entities`,
306
+ ` WHERE ${buildReadableEntitiesWhere(options)}`,
307
+ `)`,
308
+ ].join(`\n`)
309
+ }
310
+
311
+ export function buildSpawnableEntityTypesWhere(options: {
312
+ tenantId: string
313
+ principalUrl: string
314
+ principalKind: string
315
+ permissionBypass?: boolean
316
+ }): string {
317
+ const tenant = sqlStringLiteral(options.tenantId)
318
+ if (options.permissionBypass) {
319
+ return `tenant_id = ${tenant}`
320
+ }
321
+ const principalUrl = sqlStringLiteral(options.principalUrl)
322
+ const principalKind = sqlStringLiteral(options.principalKind)
323
+ return [
324
+ `tenant_id = ${tenant}`,
325
+ `AND name IN (`,
326
+ ` SELECT entity_type`,
327
+ ` FROM entity_type_permission_grants`,
328
+ ` WHERE tenant_id = ${tenant}`,
329
+ ` AND permission IN ('spawn', 'manage')`,
330
+ ` AND (`,
331
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl})`,
332
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
333
+ ` )`,
334
+ `)`,
335
+ ].join(`\n`)
336
+ }
337
+
170
338
  export async function forwardFetchRequest(options: {
171
339
  request: {
172
340
  method: string
@@ -248,17 +416,29 @@ function applyTenantShapeWhere(
248
416
  tenantId: string,
249
417
  extraConditions: Array<string> = []
250
418
  ): void {
251
- const tenantWhere = [
252
- `tenant_id = ${sqlStringLiteral(tenantId)}`,
253
- ...extraConditions,
254
- ].join(` AND `)
419
+ applyShapeWhere(
420
+ target,
421
+ [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(
422
+ ` AND `
423
+ )
424
+ )
425
+ }
426
+
427
+ function applyShapeWhere(target: URL, enforcedWhere: string): void {
255
428
  const existingWhere = target.searchParams.get(`where`)
256
429
  target.searchParams.set(
257
430
  `where`,
258
- existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere
431
+ existingWhere ? `${enforcedWhere} AND (${existingWhere})` : enforcedWhere
259
432
  )
260
433
  }
261
434
 
262
435
  function sqlStringLiteral(value: string): string {
263
436
  return `'${value.replace(/'/g, `''`)}'`
264
437
  }
438
+
439
+ function indentWhere(where: string, prefix: string): string {
440
+ return where
441
+ .split(`\n`)
442
+ .map((line) => `${prefix}${line}`)
443
+ .join(`\n`)
444
+ }
@@ -41,6 +41,12 @@ export interface WakeEvalResult {
41
41
  collection: string
42
42
  kind: `insert` | `update` | `delete`
43
43
  key: string
44
+ from?: string
45
+ from_principal?: string
46
+ from_agent?: string
47
+ payload?: unknown
48
+ timestamp?: string
49
+ message_type?: string
44
50
  }>
45
51
  }
46
52
  runFinishedStatus?: `completed` | `failed`
@@ -885,11 +891,7 @@ export class WakeRegistry {
885
891
  reg: WakeRegistration,
886
892
  event: Record<string, unknown>
887
893
  ): {
888
- change: {
889
- collection: string
890
- kind: `insert` | `update` | `delete`
891
- key: string
892
- }
894
+ change: WakeEvalResult[`wakeMessage`][`changes`][number]
893
895
  runFinishedStatus?: `completed` | `failed`
894
896
  } | null {
895
897
  if (reg.condition === `runFinished`) {
@@ -935,12 +937,29 @@ export class WakeRegistry {
935
937
  return null
936
938
  }
937
939
 
938
- return {
939
- change: {
940
- collection: eventType,
941
- kind,
942
- key: (event.key as string) || ``,
943
- },
940
+ const change: WakeEvalResult[`wakeMessage`][`changes`][number] = {
941
+ collection: eventType,
942
+ kind,
943
+ key: (event.key as string) || ``,
944
944
  }
945
+
946
+ if (eventType === `inbox`) {
947
+ const value = event.value as Record<string, unknown> | undefined
948
+ if (typeof value?.from === `string`) change.from = value.from
949
+ if (typeof value?.from_principal === `string`) {
950
+ change.from_principal = value.from_principal
951
+ }
952
+ if (typeof value?.from_agent === `string`) {
953
+ change.from_agent = value.from_agent
954
+ }
955
+ if (`payload` in (value ?? {})) change.payload = value?.payload
956
+ if (typeof value?.timestamp === `string`)
957
+ change.timestamp = value.timestamp
958
+ if (typeof value?.message_type === `string`) {
959
+ change.message_type = value.message_type
960
+ }
961
+ }
962
+
963
+ return { change }
945
964
  }
946
965
  }