@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.
@@ -3,6 +3,7 @@ import {
3
3
  assertTags,
4
4
  buildTagsIndex,
5
5
  getEntitiesStreamPath,
6
+ hashString,
6
7
  normalizeTags,
7
8
  sourceRefForTags,
8
9
  } from '@electric-ax/agents-runtime'
@@ -15,6 +16,7 @@ import { PostgresRegistry } from './entity-registry.js'
15
16
  import { electricUrlWithPath } from './utils/electric-url.js'
16
17
  import { serverLog } from './utils/log.js'
17
18
  import { isUnregisteredTenantError } from './tenant.js'
19
+ import { isBuiltInSystemPrincipalUrl } from './principal.js'
18
20
  import type { DrizzleDB } from './db/index.js'
19
21
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
20
22
  import type { EntityBridgeRow } from './entity-registry.js'
@@ -37,7 +39,9 @@ interface EntityShapeRow extends Row<unknown> {
37
39
  type: string
38
40
  status: `spawning` | `running` | `idle` | `stopped`
39
41
  tags: EntityTags
42
+ created_by?: string | null
40
43
  spawn_args?: Record<string, unknown> | null
44
+ sandbox?: { profile: string } | null
41
45
  parent?: string | null
42
46
  type_revision?: number | null
43
47
  inbox_schemas?: Record<string, Record<string, unknown>> | null
@@ -52,7 +56,9 @@ const ENTITY_SHAPE_COLUMNS = [
52
56
  `type`,
53
57
  `status`,
54
58
  `tags`,
59
+ `created_by`,
55
60
  `spawn_args`,
61
+ `sandbox`,
56
62
  `parent`,
57
63
  `type_revision`,
58
64
  `inbox_schemas`,
@@ -87,6 +93,16 @@ function sourceRefFromStreamPath(streamPath: string): string | null {
87
93
  return match?.[1] ?? null
88
94
  }
89
95
 
96
+ function principalScopedSourceRef(
97
+ tagSourceRef: string,
98
+ principalUrl: string,
99
+ principalKind: string
100
+ ): string {
101
+ return `${tagSourceRef}-${hashString(
102
+ JSON.stringify({ principalKind, principalUrl })
103
+ )}`
104
+ }
105
+
90
106
  function sameMember(
91
107
  left: EntityMembershipRow | undefined,
92
108
  right: EntityMembershipRow
@@ -108,6 +124,7 @@ function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
108
124
  status: entity.status,
109
125
  tags: entity.tags,
110
126
  spawn_args: entity.spawn_args ?? {},
127
+ sandbox: entity.sandbox ?? null,
111
128
  parent: entity.parent ?? null,
112
129
  type_revision: entity.type_revision ?? null,
113
130
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -122,6 +139,9 @@ class ProjectedEntityBridge {
122
139
  readonly sourceRef: string
123
140
  readonly tags: EntityTags
124
141
  readonly streamUrl: string
142
+ private readonly principalUrl?: string
143
+ private readonly principalKind?: string
144
+ private readonly permissionBypass: boolean
125
145
 
126
146
  private currentMembers = new Map<string, EntityMembershipRow>()
127
147
  private producer: IdempotentProducer | null = null
@@ -129,12 +149,16 @@ class ProjectedEntityBridge {
129
149
 
130
150
  constructor(
131
151
  row: EntityBridgeRow,
152
+ private registry: PostgresRegistry,
132
153
  private streamClient: StreamClient
133
154
  ) {
134
155
  this.tenantId = row.tenantId
135
156
  this.sourceRef = row.sourceRef
136
157
  this.tags = normalizeTags(row.tags)
137
158
  this.streamUrl = row.streamUrl
159
+ this.principalUrl = row.principalUrl
160
+ this.principalKind = row.principalKind
161
+ this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl)
138
162
  }
139
163
 
140
164
  async start(initialEntities: Iterable<EntityShapeRow>): Promise<void> {
@@ -156,7 +180,7 @@ class ProjectedEntityBridge {
156
180
  }
157
181
  )
158
182
  await this.loadCurrentMembers()
159
- this.reconcile(initialEntities)
183
+ await this.reconcile(initialEntities)
160
184
  }
161
185
 
162
186
  async stop(): Promise<void> {
@@ -172,13 +196,14 @@ class ProjectedEntityBridge {
172
196
  }
173
197
  }
174
198
 
175
- reconcile(entities: Iterable<EntityShapeRow>): void {
199
+ async reconcile(entities: Iterable<EntityShapeRow>): Promise<void> {
176
200
  if (this.stopped) return
177
201
 
178
202
  const staleMembers = new Map(this.currentMembers)
179
203
  for (const entity of entities) {
180
204
  if (entity.tenant_id !== this.tenantId) continue
181
205
  if (!entityMatchesTags(entity, this.tags)) continue
206
+ if (!(await this.canReadEntity(entity))) continue
182
207
  staleMembers.delete(entity.url)
183
208
  this.upsertEntity(entity)
184
209
  }
@@ -189,11 +214,14 @@ class ProjectedEntityBridge {
189
214
  }
190
215
  }
191
216
 
192
- applyEntity(entity: EntityShapeRow): void {
217
+ async applyEntity(entity: EntityShapeRow): Promise<void> {
193
218
  if (this.stopped) return
194
219
  if (entity.tenant_id !== this.tenantId) return
195
220
 
196
- if (!entityMatchesTags(entity, this.tags)) {
221
+ if (
222
+ !entityMatchesTags(entity, this.tags) ||
223
+ !(await this.canReadEntity(entity))
224
+ ) {
197
225
  const existing = this.currentMembers.get(entity.url)
198
226
  if (!existing) return
199
227
  this.append(`delete`, existing)
@@ -228,6 +256,16 @@ class ProjectedEntityBridge {
228
256
  }
229
257
  }
230
258
 
259
+ private async canReadEntity(entity: EntityShapeRow): Promise<boolean> {
260
+ if (this.permissionBypass) return true
261
+ if (!this.principalUrl || !this.principalKind) return false
262
+ if (entity.created_by === this.principalUrl) return true
263
+ return await this.registry.hasEntityPermission(entity.url, `read`, {
264
+ principalUrl: this.principalUrl,
265
+ principalKind: this.principalKind,
266
+ })
267
+ }
268
+
231
269
  private async ensureStream(): Promise<void> {
232
270
  if (!(await this.streamClient.exists(this.streamUrl))) {
233
271
  await this.streamClient.create(this.streamUrl, {
@@ -374,7 +412,9 @@ export class EntityProjector {
374
412
  async register(
375
413
  tenantId: string,
376
414
  registry: PostgresRegistry,
377
- tagsInput: unknown
415
+ tagsInput: unknown,
416
+ principalUrl: string,
417
+ principalKind: string
378
418
  ): Promise<{ sourceRef: string; streamUrl: string }> {
379
419
  if (!this.electricUrl) {
380
420
  throw new Error(
@@ -385,12 +425,18 @@ export class EntityProjector {
385
425
  await this.start()
386
426
  this.registries.set(tenantId, registry)
387
427
  const tags = normalizeTags(assertTags(tagsInput))
388
- const sourceRef = sourceRefForTags(tags)
428
+ const sourceRef = principalScopedSourceRef(
429
+ sourceRefForTags(tags),
430
+ principalUrl,
431
+ principalKind
432
+ )
389
433
  const streamUrl = getEntitiesStreamPath(sourceRef)
390
434
  const row = await registry.upsertEntityBridge({
391
435
  sourceRef,
392
436
  tags,
393
437
  streamUrl,
438
+ principalUrl,
439
+ principalKind,
394
440
  })
395
441
  await registry.touchEntityBridge(sourceRef)
396
442
  await this.ensureProjection(row)
@@ -433,8 +479,12 @@ export class EntityProjector {
433
479
  }
434
480
  }
435
481
 
436
- async onEntityChanged(_tenantId: string, _entityUrl: string): Promise<void> {
437
- // Membership updates come from the shared Electric entities shape.
482
+ async onEntityChanged(tenantId: string, entityUrl: string): Promise<void> {
483
+ const entity = this.entities.get(entityKey(tenantId, entityUrl))
484
+ if (!entity) return
485
+ for (const projection of this.projectionsForTenant(tenantId)) {
486
+ await projection.applyEntity(entity)
487
+ }
438
488
  }
439
489
 
440
490
  async loadTenantBridges(
@@ -520,18 +570,20 @@ export class EntityProjector {
520
570
  }
521
571
  if (message.headers.control === `up-to-date`) {
522
572
  this.upToDate = true
523
- this.reconcileAll()
573
+ await this.reconcileAll()
524
574
  this.readyResolve?.()
525
575
  }
526
576
  continue
527
577
  }
528
578
 
529
579
  if (!isChangeMessage(message)) continue
530
- this.applyChangeMessage(message)
580
+ await this.applyChangeMessage(message)
531
581
  }
532
582
  }
533
583
 
534
- private applyChangeMessage(message: ChangeMessage<EntityShapeRow>): void {
584
+ private async applyChangeMessage(
585
+ message: ChangeMessage<EntityShapeRow>
586
+ ): Promise<void> {
535
587
  const entity = message.value
536
588
  const key = entityKey(entity.tenant_id, entity.url)
537
589
  if (message.headers.operation === `delete`) {
@@ -547,7 +599,7 @@ export class EntityProjector {
547
599
  this.entities.set(key, entity)
548
600
  if (this.upToDate) {
549
601
  for (const projection of this.projectionsForTenant(entity.tenant_id)) {
550
- projection.applyEntity(entity)
602
+ await projection.applyEntity(entity)
551
603
  }
552
604
  }
553
605
  }
@@ -639,7 +691,11 @@ export class EntityProjector {
639
691
  }
640
692
  throw error
641
693
  }
642
- const projection = new ProjectedEntityBridge(row, streamClient)
694
+ const projection = new ProjectedEntityBridge(
695
+ row,
696
+ this.registryForTenant(row.tenantId),
697
+ streamClient
698
+ )
643
699
  await projection.start(this.entitiesForTenant(row.tenantId))
644
700
  this.projections.set(key, projection)
645
701
  })().finally(() => {
@@ -662,9 +718,9 @@ export class EntityProjector {
662
718
  )
663
719
  }
664
720
 
665
- private reconcileAll(): void {
721
+ private async reconcileAll(): Promise<void> {
666
722
  for (const projection of this.projections.values()) {
667
- projection.reconcile(this.entitiesForTenant(projection.tenantId))
723
+ await projection.reconcile(this.entitiesForTenant(projection.tenantId))
668
724
  }
669
725
  }
670
726
 
@@ -730,14 +786,20 @@ export class EntityProjectorTenantFacade implements EntityBridgeCoordinator {
730
786
 
731
787
  async stop(): Promise<void> {}
732
788
 
733
- async register(tagsInput: unknown): Promise<{
789
+ async register(
790
+ tagsInput: unknown,
791
+ principalUrl: string,
792
+ principalKind: string
793
+ ): Promise<{
734
794
  sourceRef: string
735
795
  streamUrl: string
736
796
  }> {
737
797
  return await this.projector.register(
738
798
  this.tenantId,
739
799
  this.registry,
740
- tagsInput
800
+ tagsInput,
801
+ principalUrl,
802
+ principalKind
741
803
  )
742
804
  }
743
805