@electric-ax/agents-server 0.4.15 → 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.
@@ -0,0 +1,100 @@
1
+ CREATE TABLE "entity_type_permission_grants" (
2
+ "id" bigserial PRIMARY KEY NOT NULL,
3
+ "tenant_id" text DEFAULT 'default' NOT NULL,
4
+ "entity_type" text NOT NULL,
5
+ "permission" text NOT NULL,
6
+ "subject_kind" text NOT NULL,
7
+ "subject_value" text NOT NULL,
8
+ "created_by" text,
9
+ "expires_at" timestamp with time zone,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
11
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ CONSTRAINT "chk_type_permission_grants_permission" CHECK ("entity_type_permission_grants"."permission" IN ('spawn', 'manage')),
13
+ CONSTRAINT "chk_type_permission_grants_subject_kind" CHECK ("entity_type_permission_grants"."subject_kind" IN ('principal', 'principal_kind'))
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE TABLE "entity_lineage" (
17
+ "tenant_id" text DEFAULT 'default' NOT NULL,
18
+ "ancestor_url" text NOT NULL,
19
+ "descendant_url" text NOT NULL,
20
+ "depth" integer NOT NULL,
21
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
22
+ CONSTRAINT "entity_lineage_pkey" PRIMARY KEY ("tenant_id", "ancestor_url", "descendant_url"),
23
+ CONSTRAINT "chk_entity_lineage_depth" CHECK ("entity_lineage"."depth" >= 0)
24
+ );
25
+ --> statement-breakpoint
26
+ CREATE TABLE "entity_permission_grants" (
27
+ "id" bigserial PRIMARY KEY NOT NULL,
28
+ "tenant_id" text DEFAULT 'default' NOT NULL,
29
+ "entity_url" text NOT NULL,
30
+ "permission" text NOT NULL,
31
+ "subject_kind" text NOT NULL,
32
+ "subject_value" text NOT NULL,
33
+ "propagation" text DEFAULT 'self' NOT NULL,
34
+ "copy_to_children" boolean DEFAULT false NOT NULL,
35
+ "created_by" text,
36
+ "expires_at" timestamp with time zone,
37
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
38
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
39
+ CONSTRAINT "chk_entity_permission_grants_permission" CHECK ("entity_permission_grants"."permission" IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')),
40
+ CONSTRAINT "chk_entity_permission_grants_subject_kind" CHECK ("entity_permission_grants"."subject_kind" IN ('principal', 'principal_kind')),
41
+ CONSTRAINT "chk_entity_permission_grants_propagation" CHECK ("entity_permission_grants"."propagation" IN ('self', 'descendants'))
42
+ );
43
+ --> statement-breakpoint
44
+ CREATE TABLE "entity_effective_permissions" (
45
+ "id" bigserial PRIMARY KEY NOT NULL,
46
+ "tenant_id" text DEFAULT 'default' NOT NULL,
47
+ "entity_url" text NOT NULL,
48
+ "source_entity_url" text NOT NULL,
49
+ "source_grant_id" bigint NOT NULL,
50
+ "permission" text NOT NULL,
51
+ "subject_kind" text NOT NULL,
52
+ "subject_value" text NOT NULL,
53
+ "expires_at" timestamp with time zone,
54
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
55
+ CONSTRAINT "uq_entity_effective_permission" UNIQUE ("tenant_id", "entity_url", "source_grant_id"),
56
+ CONSTRAINT "chk_entity_effective_permissions_permission" CHECK ("entity_effective_permissions"."permission" IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')),
57
+ CONSTRAINT "chk_entity_effective_permissions_subject_kind" CHECK ("entity_effective_permissions"."subject_kind" IN ('principal', 'principal_kind'))
58
+ );
59
+ --> statement-breakpoint
60
+ CREATE TABLE "shared_state_links" (
61
+ "tenant_id" text DEFAULT 'default' NOT NULL,
62
+ "shared_state_id" text NOT NULL,
63
+ "owner_entity_url" text NOT NULL,
64
+ "manifest_key" text NOT NULL,
65
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
66
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
67
+ CONSTRAINT "shared_state_links_pkey" PRIMARY KEY ("tenant_id", "owner_entity_url", "manifest_key")
68
+ );
69
+ --> statement-breakpoint
70
+ CREATE INDEX "idx_type_permission_grants_lookup" ON "entity_type_permission_grants" USING btree ("tenant_id", "entity_type", "permission", "subject_kind", "subject_value");
71
+ --> statement-breakpoint
72
+ CREATE INDEX "idx_type_permission_grants_expiry" ON "entity_type_permission_grants" USING btree ("tenant_id", "expires_at");
73
+ --> statement-breakpoint
74
+ CREATE INDEX "idx_entity_lineage_descendant" ON "entity_lineage" USING btree ("tenant_id", "descendant_url");
75
+ --> statement-breakpoint
76
+ CREATE INDEX "idx_entity_permission_grants_entity" ON "entity_permission_grants" USING btree ("tenant_id", "entity_url");
77
+ --> statement-breakpoint
78
+ CREATE INDEX "idx_entity_permission_grants_subject" ON "entity_permission_grants" USING btree ("tenant_id", "permission", "subject_kind", "subject_value");
79
+ --> statement-breakpoint
80
+ CREATE INDEX "idx_entity_permission_grants_expiry" ON "entity_permission_grants" USING btree ("tenant_id", "expires_at");
81
+ --> statement-breakpoint
82
+ CREATE INDEX "idx_entity_effective_permissions_lookup" ON "entity_effective_permissions" USING btree ("tenant_id", "permission", "subject_kind", "subject_value", "entity_url");
83
+ --> statement-breakpoint
84
+ CREATE INDEX "idx_entity_effective_permissions_entity" ON "entity_effective_permissions" USING btree ("tenant_id", "entity_url");
85
+ --> statement-breakpoint
86
+ CREATE INDEX "idx_entity_effective_permissions_expiry" ON "entity_effective_permissions" USING btree ("tenant_id", "expires_at");
87
+ --> statement-breakpoint
88
+ CREATE INDEX "idx_shared_state_links_shared_state" ON "shared_state_links" USING btree ("tenant_id", "shared_state_id");
89
+ --> statement-breakpoint
90
+ CREATE INDEX "idx_shared_state_links_owner" ON "shared_state_links" USING btree ("tenant_id", "owner_entity_url");
91
+ --> statement-breakpoint
92
+ -- Pre-permission entity bridge rows do not carry principal attribution. Drop them
93
+ -- so observation bridges are rebuilt with principal_url/principal_kind scoping.
94
+ DELETE FROM "entity_bridges";
95
+ --> statement-breakpoint
96
+ ALTER TABLE "entity_bridges" ADD COLUMN "principal_url" text;
97
+ --> statement-breakpoint
98
+ ALTER TABLE "entity_bridges" ADD COLUMN "principal_kind" text;
99
+ --> statement-breakpoint
100
+ CREATE INDEX "idx_entity_bridges_principal" ON "entity_bridges" USING btree ("tenant_id", "principal_kind", "principal_url");
@@ -0,0 +1,25 @@
1
+ INSERT INTO "entity_type_permission_grants" (
2
+ "tenant_id",
3
+ "entity_type",
4
+ "permission",
5
+ "subject_kind",
6
+ "subject_value"
7
+ )
8
+ SELECT
9
+ "entity_types"."tenant_id",
10
+ "entity_types"."name",
11
+ 'manage',
12
+ 'principal_kind',
13
+ 'user'
14
+ FROM "entity_types"
15
+ WHERE "entity_types"."name" = 'horton'
16
+ AND NOT EXISTS (
17
+ SELECT 1
18
+ FROM "entity_type_permission_grants"
19
+ WHERE "entity_type_permission_grants"."tenant_id" = "entity_types"."tenant_id"
20
+ AND "entity_type_permission_grants"."entity_type" = "entity_types"."name"
21
+ AND "entity_type_permission_grants"."permission" = 'manage'
22
+ AND "entity_type_permission_grants"."subject_kind" = 'principal_kind'
23
+ AND "entity_type_permission_grants"."subject_value" = 'user'
24
+ AND "entity_type_permission_grants"."expires_at" IS NULL
25
+ );
@@ -0,0 +1,25 @@
1
+ INSERT INTO "entity_type_permission_grants" (
2
+ "tenant_id",
3
+ "entity_type",
4
+ "permission",
5
+ "subject_kind",
6
+ "subject_value"
7
+ )
8
+ SELECT
9
+ "entity_types"."tenant_id",
10
+ "entity_types"."name",
11
+ 'manage',
12
+ 'principal_kind',
13
+ 'user'
14
+ FROM "entity_types"
15
+ WHERE "entity_types"."name" = 'worker'
16
+ AND NOT EXISTS (
17
+ SELECT 1
18
+ FROM "entity_type_permission_grants"
19
+ WHERE "entity_type_permission_grants"."tenant_id" = "entity_types"."tenant_id"
20
+ AND "entity_type_permission_grants"."entity_type" = "entity_types"."name"
21
+ AND "entity_type_permission_grants"."permission" = 'manage'
22
+ AND "entity_type_permission_grants"."subject_kind" = 'principal_kind'
23
+ AND "entity_type_permission_grants"."subject_value" = 'user'
24
+ AND "entity_type_permission_grants"."expires_at" IS NULL
25
+ );
@@ -78,6 +78,27 @@
78
78
  "when": 1779062400000,
79
79
  "tag": "0010_sandbox_profiles",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1779050000000,
86
+ "tag": "0011_entity_permissions",
87
+ "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "7",
92
+ "when": 1780584581000,
93
+ "tag": "0012_horton_user_manage_permission",
94
+ "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "7",
99
+ "when": 1780588695000,
100
+ "tag": "0013_worker_user_manage_permission",
101
+ "breakpoints": true
81
102
  }
82
103
  ]
83
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -37,8 +37,8 @@
37
37
  "dependencies": {
38
38
  "@anthropic-ai/sdk": "^0.78.0",
39
39
  "@durable-streams/client": "^0.2.6",
40
- "@durable-streams/server": "^0.3.5",
41
- "@durable-streams/state": "^0.2.9",
40
+ "@durable-streams/server": "^0.3.7",
41
+ "@durable-streams/state": "^0.3.1",
42
42
  "@electric-sql/client": "^1.5.20",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.8"
57
+ "@electric-ax/agents-runtime": "0.3.9"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.12",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.10",
70
- "@electric-ax/agents-server-ui": "0.4.15"
68
+ "@electric-ax/agents": "0.4.13",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.11",
70
+ "@electric-ax/agents-server-ui": "0.4.16"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -72,6 +72,197 @@ export const entities = pgTable(
72
72
  ]
73
73
  )
74
74
 
75
+ export const entityTypePermissionGrants = pgTable(
76
+ `entity_type_permission_grants`,
77
+ {
78
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
79
+ tenantId: text(`tenant_id`).notNull().default(`default`),
80
+ entityType: text(`entity_type`).notNull(),
81
+ permission: text(`permission`).notNull(),
82
+ subjectKind: text(`subject_kind`).notNull(),
83
+ subjectValue: text(`subject_value`).notNull(),
84
+ createdBy: text(`created_by`),
85
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
86
+ createdAt: timestamp(`created_at`, { withTimezone: true })
87
+ .notNull()
88
+ .defaultNow(),
89
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
90
+ .notNull()
91
+ .defaultNow(),
92
+ },
93
+ (table) => [
94
+ index(`idx_type_permission_grants_lookup`).on(
95
+ table.tenantId,
96
+ table.entityType,
97
+ table.permission,
98
+ table.subjectKind,
99
+ table.subjectValue
100
+ ),
101
+ index(`idx_type_permission_grants_expiry`).on(
102
+ table.tenantId,
103
+ table.expiresAt
104
+ ),
105
+ check(
106
+ `chk_type_permission_grants_permission`,
107
+ sql`${table.permission} IN ('spawn', 'manage')`
108
+ ),
109
+ check(
110
+ `chk_type_permission_grants_subject_kind`,
111
+ sql`${table.subjectKind} IN ('principal', 'principal_kind')`
112
+ ),
113
+ ]
114
+ )
115
+
116
+ export const entityLineage = pgTable(
117
+ `entity_lineage`,
118
+ {
119
+ tenantId: text(`tenant_id`).notNull().default(`default`),
120
+ ancestorUrl: text(`ancestor_url`).notNull(),
121
+ descendantUrl: text(`descendant_url`).notNull(),
122
+ depth: integer(`depth`).notNull(),
123
+ createdAt: timestamp(`created_at`, { withTimezone: true })
124
+ .notNull()
125
+ .defaultNow(),
126
+ },
127
+ (table) => [
128
+ primaryKey({
129
+ columns: [table.tenantId, table.ancestorUrl, table.descendantUrl],
130
+ }),
131
+ index(`idx_entity_lineage_descendant`).on(
132
+ table.tenantId,
133
+ table.descendantUrl
134
+ ),
135
+ check(`chk_entity_lineage_depth`, sql`${table.depth} >= 0`),
136
+ ]
137
+ )
138
+
139
+ export const entityPermissionGrants = pgTable(
140
+ `entity_permission_grants`,
141
+ {
142
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
143
+ tenantId: text(`tenant_id`).notNull().default(`default`),
144
+ entityUrl: text(`entity_url`).notNull(),
145
+ permission: text(`permission`).notNull(),
146
+ subjectKind: text(`subject_kind`).notNull(),
147
+ subjectValue: text(`subject_value`).notNull(),
148
+ propagation: text(`propagation`).notNull().default(`self`),
149
+ copyToChildren: boolean(`copy_to_children`).notNull().default(false),
150
+ createdBy: text(`created_by`),
151
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
152
+ createdAt: timestamp(`created_at`, { withTimezone: true })
153
+ .notNull()
154
+ .defaultNow(),
155
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
156
+ .notNull()
157
+ .defaultNow(),
158
+ },
159
+ (table) => [
160
+ index(`idx_entity_permission_grants_entity`).on(
161
+ table.tenantId,
162
+ table.entityUrl
163
+ ),
164
+ index(`idx_entity_permission_grants_subject`).on(
165
+ table.tenantId,
166
+ table.permission,
167
+ table.subjectKind,
168
+ table.subjectValue
169
+ ),
170
+ index(`idx_entity_permission_grants_expiry`).on(
171
+ table.tenantId,
172
+ table.expiresAt
173
+ ),
174
+ check(
175
+ `chk_entity_permission_grants_permission`,
176
+ sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`
177
+ ),
178
+ check(
179
+ `chk_entity_permission_grants_subject_kind`,
180
+ sql`${table.subjectKind} IN ('principal', 'principal_kind')`
181
+ ),
182
+ check(
183
+ `chk_entity_permission_grants_propagation`,
184
+ sql`${table.propagation} IN ('self', 'descendants')`
185
+ ),
186
+ ]
187
+ )
188
+
189
+ export const entityEffectivePermissions = pgTable(
190
+ `entity_effective_permissions`,
191
+ {
192
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
193
+ tenantId: text(`tenant_id`).notNull().default(`default`),
194
+ entityUrl: text(`entity_url`).notNull(),
195
+ sourceEntityUrl: text(`source_entity_url`).notNull(),
196
+ sourceGrantId: bigint(`source_grant_id`, { mode: `number` }).notNull(),
197
+ permission: text(`permission`).notNull(),
198
+ subjectKind: text(`subject_kind`).notNull(),
199
+ subjectValue: text(`subject_value`).notNull(),
200
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
201
+ createdAt: timestamp(`created_at`, { withTimezone: true })
202
+ .notNull()
203
+ .defaultNow(),
204
+ },
205
+ (table) => [
206
+ unique(`uq_entity_effective_permission`).on(
207
+ table.tenantId,
208
+ table.entityUrl,
209
+ table.sourceGrantId
210
+ ),
211
+ index(`idx_entity_effective_permissions_lookup`).on(
212
+ table.tenantId,
213
+ table.permission,
214
+ table.subjectKind,
215
+ table.subjectValue,
216
+ table.entityUrl
217
+ ),
218
+ index(`idx_entity_effective_permissions_entity`).on(
219
+ table.tenantId,
220
+ table.entityUrl
221
+ ),
222
+ index(`idx_entity_effective_permissions_expiry`).on(
223
+ table.tenantId,
224
+ table.expiresAt
225
+ ),
226
+ check(
227
+ `chk_entity_effective_permissions_permission`,
228
+ sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`
229
+ ),
230
+ check(
231
+ `chk_entity_effective_permissions_subject_kind`,
232
+ sql`${table.subjectKind} IN ('principal', 'principal_kind')`
233
+ ),
234
+ ]
235
+ )
236
+
237
+ export const sharedStateLinks = pgTable(
238
+ `shared_state_links`,
239
+ {
240
+ tenantId: text(`tenant_id`).notNull().default(`default`),
241
+ sharedStateId: text(`shared_state_id`).notNull(),
242
+ ownerEntityUrl: text(`owner_entity_url`).notNull(),
243
+ manifestKey: text(`manifest_key`).notNull(),
244
+ createdAt: timestamp(`created_at`, { withTimezone: true })
245
+ .notNull()
246
+ .defaultNow(),
247
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
248
+ .notNull()
249
+ .defaultNow(),
250
+ },
251
+ (table) => [
252
+ primaryKey({
253
+ columns: [table.tenantId, table.ownerEntityUrl, table.manifestKey],
254
+ }),
255
+ index(`idx_shared_state_links_shared_state`).on(
256
+ table.tenantId,
257
+ table.sharedStateId
258
+ ),
259
+ index(`idx_shared_state_links_owner`).on(
260
+ table.tenantId,
261
+ table.ownerEntityUrl
262
+ ),
263
+ ]
264
+ )
265
+
75
266
  export const users = pgTable(
76
267
  `users`,
77
268
  {
@@ -436,6 +627,8 @@ export const entityBridges = pgTable(
436
627
  sourceRef: text(`source_ref`).notNull(),
437
628
  tags: jsonb(`tags`).notNull(),
438
629
  streamUrl: text(`stream_url`).notNull(),
630
+ principalUrl: text(`principal_url`),
631
+ principalKind: text(`principal_kind`),
439
632
  shapeHandle: text(`shape_handle`),
440
633
  shapeOffset: text(`shape_offset`),
441
634
  lastObserverActivityAt: timestamp(`last_observer_activity_at`, {
@@ -453,6 +646,11 @@ export const entityBridges = pgTable(
453
646
  (table) => [
454
647
  primaryKey({ columns: [table.tenantId, table.sourceRef] }),
455
648
  unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl),
649
+ index(`idx_entity_bridges_principal`).on(
650
+ table.tenantId,
651
+ table.principalKind,
652
+ table.principalUrl
653
+ ),
456
654
  ]
457
655
  )
458
656
 
@@ -67,6 +67,78 @@ export type RunnerKind = `local` | `cloud-worker` | `sandbox` | `ci` | `server`
67
67
  export type RunnerAdminStatus = `enabled` | `disabled`
68
68
  export type RunnerLiveness = `online` | `offline`
69
69
 
70
+ export type PermissionSubjectKind = `principal` | `principal_kind`
71
+ export type PermissionSubject = {
72
+ subject_kind: PermissionSubjectKind
73
+ subject_value: string
74
+ }
75
+ export type EntityPermission =
76
+ | `read`
77
+ | `write`
78
+ | `delete`
79
+ | `signal`
80
+ | `fork`
81
+ | `schedule`
82
+ | `spawn`
83
+ | `manage`
84
+ export type EntityTypePermission = `spawn` | `manage`
85
+ export type EntityPermissionPropagation = `self` | `descendants`
86
+
87
+ export interface EntityPermissionGrant extends PermissionSubject {
88
+ id: number
89
+ entity_url: string
90
+ permission: EntityPermission
91
+ propagation: EntityPermissionPropagation
92
+ copy_to_children: boolean
93
+ created_by?: string
94
+ expires_at?: string
95
+ created_at: string
96
+ updated_at: string
97
+ }
98
+
99
+ export interface EntityTypePermissionGrant extends PermissionSubject {
100
+ id: number
101
+ entity_type: string
102
+ permission: EntityTypePermission
103
+ created_by?: string
104
+ expires_at?: string
105
+ created_at: string
106
+ updated_at: string
107
+ }
108
+
109
+ export interface EntityTypePermissionGrantInput extends PermissionSubject {
110
+ permission: EntityTypePermission
111
+ expires_at?: string
112
+ }
113
+
114
+ export type AuthorizationResource =
115
+ | { kind: `entity`; entity: ElectricAgentsEntity }
116
+ | { kind: `entity_type`; entityType: ElectricAgentsEntityType }
117
+ | { kind: `entity_type_registration`; entityTypeName: string }
118
+ | {
119
+ kind: `shared_state`
120
+ sharedStateId: string
121
+ linkedEntityUrls: Array<string>
122
+ }
123
+
124
+ export type AuthorizationDecision = {
125
+ decision: `allow` | `deny`
126
+ expires_at?: string
127
+ }
128
+
129
+ export type AuthorizeRequest = (input: {
130
+ tenant: string
131
+ principal: Principal
132
+ verb: EntityPermission | EntityTypePermission
133
+ resource: AuthorizationResource
134
+ request?: {
135
+ method: string
136
+ url: string
137
+ headers: Record<string, string>
138
+ }
139
+ builtInAllowed: boolean
140
+ }) => Promise<AuthorizationDecision> | AuthorizationDecision
141
+
70
142
  const VALID_RUNNER_KINDS = new Set<string>([
71
143
  `local`,
72
144
  `cloud-worker`,
@@ -356,7 +428,6 @@ export interface ElectricAgentsEntity {
356
428
  status: EntityStatus
357
429
  streams: {
358
430
  main: string
359
- error: string
360
431
  }
361
432
  subscription_id: string
362
433
  dispatch_policy?: DispatchPolicy
@@ -385,7 +456,7 @@ export interface PublicElectricAgentsEntity {
385
456
  url: string
386
457
  type: string
387
458
  status: EntityStatus
388
- streams: { main: string; error: string }
459
+ streams: { main: string }
389
460
  dispatch_policy?: DispatchPolicy
390
461
  tags: Record<string, string>
391
462
  spawn_args?: Record<string, unknown>
@@ -443,6 +514,7 @@ export interface RegisterEntityTypeRequest {
443
514
  state_schemas?: Record<string, Record<string, unknown>>
444
515
  serve_endpoint?: string
445
516
  default_dispatch_policy?: DispatchPolicy
517
+ permission_grants?: Array<EntityTypePermissionGrantInput>
446
518
  }
447
519
 
448
520
  export interface TypedSpawnRequest {
@@ -477,6 +549,8 @@ export interface TypedSpawnRequest {
477
549
 
478
550
  export interface SendRequest {
479
551
  from?: string
552
+ from_principal?: string
553
+ from_agent?: string
480
554
  payload?: unknown
481
555
  key?: string
482
556
  type?: string
@@ -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'
@@ -13,7 +14,9 @@ import {
13
14
  } from '@electric-sql/client'
14
15
  import { serverLog } from './utils/log.js'
15
16
  import { electricUrlWithPath } from './utils/electric-url.js'
17
+ import { buildReadableEntitiesWhere } from './utils/server-utils.js'
16
18
  import { DEFAULT_TENANT_ID } from './tenant.js'
19
+ import { isBuiltInSystemPrincipalUrl } from './principal.js'
17
20
  import type { EntityBridgeRow, PostgresRegistry } from './entity-registry.js'
18
21
  import type { StreamClient } from './stream-client.js'
19
22
  import type {
@@ -30,7 +33,11 @@ import type {
30
33
  export interface EntityBridgeCoordinator {
31
34
  start(): Promise<void>
32
35
  stop(): Promise<void>
33
- register(tagsInput: unknown): Promise<{
36
+ register(
37
+ tagsInput: unknown,
38
+ principalUrl: string,
39
+ principalKind: string
40
+ ): Promise<{
34
41
  sourceRef: string
35
42
  streamUrl: string
36
43
  }>
@@ -115,19 +122,44 @@ function sqlStringLiteral(value: string): string {
115
122
 
116
123
  function buildTenantTagsWhereClause(
117
124
  tenantId: string,
118
- tags: EntityTags
125
+ tags: EntityTags,
126
+ principalUrl?: string,
127
+ principalKind?: string,
128
+ permissionBypass?: boolean
119
129
  ): string {
120
- return `tenant_id = ${sqlStringLiteral(tenantId)} AND (${buildTagsWhereClause(tags)})`
130
+ const readableWhere =
131
+ principalUrl && principalKind
132
+ ? buildReadableEntitiesWhere({
133
+ tenantId,
134
+ principalUrl,
135
+ principalKind,
136
+ permissionBypass,
137
+ })
138
+ : `tenant_id = ${sqlStringLiteral(tenantId)} AND FALSE`
139
+ return `${readableWhere} AND (${buildTagsWhereClause(tags)})`
121
140
  }
122
141
 
123
142
  function shapeEntityKey(message: ChangeMessage<EntityShapeRow>): string {
124
143
  return message.value.url
125
144
  }
126
145
 
146
+ function principalScopedSourceRef(
147
+ tagSourceRef: string,
148
+ principalUrl: string,
149
+ principalKind: string
150
+ ): string {
151
+ return `${tagSourceRef}-${hashString(
152
+ JSON.stringify({ principalKind, principalUrl })
153
+ )}`
154
+ }
155
+
127
156
  class EntityBridge {
128
157
  readonly sourceRef: string
129
158
  readonly tags: EntityTags
130
159
  readonly streamUrl: string
160
+ private readonly principalUrl?: string
161
+ private readonly principalKind?: string
162
+ private readonly permissionBypass: boolean
131
163
 
132
164
  private currentMembers = new Map<string, EntityMembershipRow>()
133
165
  private producer: IdempotentProducer | null = null
@@ -152,6 +184,9 @@ class EntityBridge {
152
184
  this.sourceRef = row.sourceRef
153
185
  this.tags = normalizeTags(row.tags)
154
186
  this.streamUrl = row.streamUrl
187
+ this.principalUrl = row.principalUrl
188
+ this.principalKind = row.principalKind
189
+ this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl)
155
190
  this.initialShapeHandle = row.shapeHandle
156
191
  this.initialShapeOffset = row.shapeOffset
157
192
  }
@@ -316,7 +351,13 @@ class EntityBridge {
316
351
  url: electricUrlWithPath(this.electricUrl, `/v1/shape`).toString(),
317
352
  params: {
318
353
  table: `entities`,
319
- where: buildTenantTagsWhereClause(this.tenantId, this.tags),
354
+ where: buildTenantTagsWhereClause(
355
+ this.tenantId,
356
+ this.tags,
357
+ this.principalUrl,
358
+ this.principalKind,
359
+ this.permissionBypass
360
+ ),
320
361
  ...(this.electricSecret ? { secret: this.electricSecret } : {}),
321
362
  columns: [...ENTITY_SHAPE_COLUMNS],
322
363
  replica: `full`,
@@ -564,7 +605,11 @@ export class EntityBridgeManager implements EntityBridgeCoordinator {
564
605
  )
565
606
  }
566
607
 
567
- async register(tagsInput: unknown): Promise<{
608
+ async register(
609
+ tagsInput: unknown,
610
+ principalUrl: string,
611
+ principalKind: string
612
+ ): Promise<{
568
613
  sourceRef: string
569
614
  streamUrl: string
570
615
  }> {
@@ -573,13 +618,19 @@ export class EntityBridgeManager implements EntityBridgeCoordinator {
573
618
  }
574
619
 
575
620
  const tags = normalizeTags(assertTags(tagsInput))
576
- const sourceRef = sourceRefForTags(tags)
621
+ const sourceRef = principalScopedSourceRef(
622
+ sourceRefForTags(tags),
623
+ principalUrl,
624
+ principalKind
625
+ )
577
626
  const streamUrl = getEntitiesStreamPath(sourceRef)
578
627
 
579
628
  const row = await this.registry.upsertEntityBridge({
580
629
  sourceRef,
581
630
  tags,
582
631
  streamUrl,
632
+ principalUrl,
633
+ principalKind,
583
634
  })
584
635
  await this.registry.touchEntityBridge(sourceRef)
585
636
  await this.ensureBridge(row)