@electric-ax/agents-server 0.4.15 → 0.4.17

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,6 +39,7 @@ 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
41
44
  sandbox?: { profile: string } | null
42
45
  parent?: string | null
@@ -53,6 +56,7 @@ const ENTITY_SHAPE_COLUMNS = [
53
56
  `type`,
54
57
  `status`,
55
58
  `tags`,
59
+ `created_by`,
56
60
  `spawn_args`,
57
61
  `sandbox`,
58
62
  `parent`,
@@ -89,6 +93,16 @@ function sourceRefFromStreamPath(streamPath: string): string | null {
89
93
  return match?.[1] ?? null
90
94
  }
91
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
+
92
106
  function sameMember(
93
107
  left: EntityMembershipRow | undefined,
94
108
  right: EntityMembershipRow
@@ -125,6 +139,9 @@ class ProjectedEntityBridge {
125
139
  readonly sourceRef: string
126
140
  readonly tags: EntityTags
127
141
  readonly streamUrl: string
142
+ private readonly principalUrl?: string
143
+ private readonly principalKind?: string
144
+ private readonly permissionBypass: boolean
128
145
 
129
146
  private currentMembers = new Map<string, EntityMembershipRow>()
130
147
  private producer: IdempotentProducer | null = null
@@ -132,12 +149,16 @@ class ProjectedEntityBridge {
132
149
 
133
150
  constructor(
134
151
  row: EntityBridgeRow,
152
+ private registry: PostgresRegistry,
135
153
  private streamClient: StreamClient
136
154
  ) {
137
155
  this.tenantId = row.tenantId
138
156
  this.sourceRef = row.sourceRef
139
157
  this.tags = normalizeTags(row.tags)
140
158
  this.streamUrl = row.streamUrl
159
+ this.principalUrl = row.principalUrl
160
+ this.principalKind = row.principalKind
161
+ this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl)
141
162
  }
142
163
 
143
164
  async start(initialEntities: Iterable<EntityShapeRow>): Promise<void> {
@@ -159,7 +180,7 @@ class ProjectedEntityBridge {
159
180
  }
160
181
  )
161
182
  await this.loadCurrentMembers()
162
- this.reconcile(initialEntities)
183
+ await this.reconcile(initialEntities)
163
184
  }
164
185
 
165
186
  async stop(): Promise<void> {
@@ -175,13 +196,14 @@ class ProjectedEntityBridge {
175
196
  }
176
197
  }
177
198
 
178
- reconcile(entities: Iterable<EntityShapeRow>): void {
199
+ async reconcile(entities: Iterable<EntityShapeRow>): Promise<void> {
179
200
  if (this.stopped) return
180
201
 
181
202
  const staleMembers = new Map(this.currentMembers)
182
203
  for (const entity of entities) {
183
204
  if (entity.tenant_id !== this.tenantId) continue
184
205
  if (!entityMatchesTags(entity, this.tags)) continue
206
+ if (!(await this.canReadEntity(entity))) continue
185
207
  staleMembers.delete(entity.url)
186
208
  this.upsertEntity(entity)
187
209
  }
@@ -192,11 +214,14 @@ class ProjectedEntityBridge {
192
214
  }
193
215
  }
194
216
 
195
- applyEntity(entity: EntityShapeRow): void {
217
+ async applyEntity(entity: EntityShapeRow): Promise<void> {
196
218
  if (this.stopped) return
197
219
  if (entity.tenant_id !== this.tenantId) return
198
220
 
199
- if (!entityMatchesTags(entity, this.tags)) {
221
+ if (
222
+ !entityMatchesTags(entity, this.tags) ||
223
+ !(await this.canReadEntity(entity))
224
+ ) {
200
225
  const existing = this.currentMembers.get(entity.url)
201
226
  if (!existing) return
202
227
  this.append(`delete`, existing)
@@ -231,6 +256,16 @@ class ProjectedEntityBridge {
231
256
  }
232
257
  }
233
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
+
234
269
  private async ensureStream(): Promise<void> {
235
270
  if (!(await this.streamClient.exists(this.streamUrl))) {
236
271
  await this.streamClient.create(this.streamUrl, {
@@ -377,7 +412,9 @@ export class EntityProjector {
377
412
  async register(
378
413
  tenantId: string,
379
414
  registry: PostgresRegistry,
380
- tagsInput: unknown
415
+ tagsInput: unknown,
416
+ principalUrl: string,
417
+ principalKind: string
381
418
  ): Promise<{ sourceRef: string; streamUrl: string }> {
382
419
  if (!this.electricUrl) {
383
420
  throw new Error(
@@ -388,12 +425,18 @@ export class EntityProjector {
388
425
  await this.start()
389
426
  this.registries.set(tenantId, registry)
390
427
  const tags = normalizeTags(assertTags(tagsInput))
391
- const sourceRef = sourceRefForTags(tags)
428
+ const sourceRef = principalScopedSourceRef(
429
+ sourceRefForTags(tags),
430
+ principalUrl,
431
+ principalKind
432
+ )
392
433
  const streamUrl = getEntitiesStreamPath(sourceRef)
393
434
  const row = await registry.upsertEntityBridge({
394
435
  sourceRef,
395
436
  tags,
396
437
  streamUrl,
438
+ principalUrl,
439
+ principalKind,
397
440
  })
398
441
  await registry.touchEntityBridge(sourceRef)
399
442
  await this.ensureProjection(row)
@@ -436,8 +479,12 @@ export class EntityProjector {
436
479
  }
437
480
  }
438
481
 
439
- async onEntityChanged(_tenantId: string, _entityUrl: string): Promise<void> {
440
- // 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
+ }
441
488
  }
442
489
 
443
490
  async loadTenantBridges(
@@ -523,18 +570,20 @@ export class EntityProjector {
523
570
  }
524
571
  if (message.headers.control === `up-to-date`) {
525
572
  this.upToDate = true
526
- this.reconcileAll()
573
+ await this.reconcileAll()
527
574
  this.readyResolve?.()
528
575
  }
529
576
  continue
530
577
  }
531
578
 
532
579
  if (!isChangeMessage(message)) continue
533
- this.applyChangeMessage(message)
580
+ await this.applyChangeMessage(message)
534
581
  }
535
582
  }
536
583
 
537
- private applyChangeMessage(message: ChangeMessage<EntityShapeRow>): void {
584
+ private async applyChangeMessage(
585
+ message: ChangeMessage<EntityShapeRow>
586
+ ): Promise<void> {
538
587
  const entity = message.value
539
588
  const key = entityKey(entity.tenant_id, entity.url)
540
589
  if (message.headers.operation === `delete`) {
@@ -550,7 +599,7 @@ export class EntityProjector {
550
599
  this.entities.set(key, entity)
551
600
  if (this.upToDate) {
552
601
  for (const projection of this.projectionsForTenant(entity.tenant_id)) {
553
- projection.applyEntity(entity)
602
+ await projection.applyEntity(entity)
554
603
  }
555
604
  }
556
605
  }
@@ -642,7 +691,11 @@ export class EntityProjector {
642
691
  }
643
692
  throw error
644
693
  }
645
- const projection = new ProjectedEntityBridge(row, streamClient)
694
+ const projection = new ProjectedEntityBridge(
695
+ row,
696
+ this.registryForTenant(row.tenantId),
697
+ streamClient
698
+ )
646
699
  await projection.start(this.entitiesForTenant(row.tenantId))
647
700
  this.projections.set(key, projection)
648
701
  })().finally(() => {
@@ -665,9 +718,9 @@ export class EntityProjector {
665
718
  )
666
719
  }
667
720
 
668
- private reconcileAll(): void {
721
+ private async reconcileAll(): Promise<void> {
669
722
  for (const projection of this.projections.values()) {
670
- projection.reconcile(this.entitiesForTenant(projection.tenantId))
723
+ await projection.reconcile(this.entitiesForTenant(projection.tenantId))
671
724
  }
672
725
  }
673
726
 
@@ -733,14 +786,20 @@ export class EntityProjectorTenantFacade implements EntityBridgeCoordinator {
733
786
 
734
787
  async stop(): Promise<void> {}
735
788
 
736
- async register(tagsInput: unknown): Promise<{
789
+ async register(
790
+ tagsInput: unknown,
791
+ principalUrl: string,
792
+ principalKind: string
793
+ ): Promise<{
737
794
  sourceRef: string
738
795
  streamUrl: string
739
796
  }> {
740
797
  return await this.projector.register(
741
798
  this.tenantId,
742
799
  this.registry,
743
- tagsInput
800
+ tagsInput,
801
+ principalUrl,
802
+ principalKind
744
803
  )
745
804
  }
746
805