@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.
@@ -1,15 +1,21 @@
1
- import { and, desc, eq, lt, ne, sql } from 'drizzle-orm'
1
+ import { and, desc, eq, inArray, lt, ne, sql } from 'drizzle-orm'
2
2
  import { buildTagsIndex, normalizeTags } from '@electric-ax/agents-runtime'
3
3
  import {
4
4
  consumerClaims,
5
5
  entities,
6
+ entityEffectivePermissions,
6
7
  entityBridges,
7
8
  entityDispatchState,
8
9
  entityManifestSources,
10
+ entityLineage,
11
+ entityPermissionGrants,
9
12
  entityTypes,
13
+ entityTypePermissionGrants,
10
14
  runnerRuntimeDiagnostics,
11
15
  runners,
16
+ sharedStateLinks,
12
17
  tagStreamOutbox,
18
+ users,
13
19
  } from './db/schema.js'
14
20
  import {
15
21
  assertEntityStatus,
@@ -26,11 +32,19 @@ import type {
26
32
  EntityStatus,
27
33
  RunnerAdminStatus,
28
34
  RunnerKind,
35
+ SandboxProfileAdvertisement,
29
36
  SourceStreamOffset,
30
37
  ConsumerClaim,
31
38
  DispatchPolicy,
39
+ EntityPermission,
40
+ EntityPermissionGrant,
41
+ EntityPermissionPropagation,
42
+ EntityTypePermission,
43
+ EntityTypePermissionGrant,
44
+ PermissionSubjectKind,
32
45
  } from './electric-agents-types.js'
33
46
  import type { EntityTags } from '@electric-ax/agents-runtime'
47
+ import type { Principal } from './principal.js'
34
48
 
35
49
  export class EntityAlreadyExistsError extends Error {
36
50
  constructor(public readonly url: string) {
@@ -50,6 +64,8 @@ export interface EntityBridgeRow {
50
64
  sourceRef: string
51
65
  tags: EntityTags
52
66
  streamUrl: string
67
+ principalUrl?: string
68
+ principalKind?: string
53
69
  shapeHandle?: string
54
70
  shapeOffset?: string
55
71
  lastObserverActivityAt: Date
@@ -80,6 +96,13 @@ export interface RegisterRunnerInput {
80
96
  kind?: RunnerKind
81
97
  adminStatus?: RunnerAdminStatus
82
98
  wakeStream?: string
99
+ /**
100
+ * Full-set replacement: provide the complete list of profiles the
101
+ * runner currently exposes. Existing rows for the runner are
102
+ * removed and the supplied set is inserted in one transaction.
103
+ * Omit (or pass undefined) to leave the existing set untouched.
104
+ */
105
+ sandboxProfiles?: ReadonlyArray<SandboxProfileAdvertisement>
83
106
  }
84
107
 
85
108
  export interface HeartbeatRunnerInput {
@@ -127,13 +150,43 @@ export interface MaterializeReleasedClaimInput {
127
150
  releasedAt?: Date
128
151
  }
129
152
 
153
+ export interface CreateEntityTypePermissionGrantInput {
154
+ entityType: string
155
+ permission: EntityTypePermission
156
+ subjectKind: PermissionSubjectKind
157
+ subjectValue: string
158
+ createdBy?: string
159
+ expiresAt?: Date
160
+ }
161
+
162
+ export interface CreateEntityPermissionGrantInput {
163
+ entityUrl: string
164
+ permission: EntityPermission
165
+ subjectKind: PermissionSubjectKind
166
+ subjectValue: string
167
+ propagation?: EntityPermissionPropagation
168
+ copyToChildren?: boolean
169
+ createdBy?: string
170
+ expiresAt?: Date
171
+ }
172
+
130
173
  const DEFAULT_RUNNER_LEASE_MS = 30_000
174
+ const PERMISSION_PRUNE_INTERVAL_MS = 30_000
175
+
176
+ type RegistryTransaction = Parameters<
177
+ Parameters<DrizzleDB[`transaction`]>[0]
178
+ >[0]
131
179
 
132
180
  export function runnerWakeStream(runnerId: string): string {
133
181
  return `/runners/${runnerId}/wake`
134
182
  }
135
183
 
136
184
  export class PostgresRegistry {
185
+ // Electric predicates cannot depend on now(), so expired effective rows need a
186
+ // server-side sweep. Debounce it to keep read/auth paths from writing on every request.
187
+ private lastPermissionPruneStartedAt = 0
188
+ private permissionPrunePromise: Promise<void> | null = null
189
+
137
190
  constructor(
138
191
  private db: DrizzleDB,
139
192
  readonly tenantId: string = DEFAULT_TENANT_ID
@@ -143,11 +196,34 @@ export class PostgresRegistry {
143
196
 
144
197
  close(): void {}
145
198
 
199
+ async ensureUserForPrincipal(principal: Principal): Promise<void> {
200
+ if (principal.kind !== `user`) return
201
+
202
+ await this.db
203
+ .insert(users)
204
+ .values({
205
+ tenantId: this.tenantId,
206
+ id: principal.id,
207
+ })
208
+ .onConflictDoNothing()
209
+ }
210
+
146
211
  async createRunner(
147
212
  input: RegisterRunnerInput
148
213
  ): Promise<ElectricAgentsRunner> {
149
214
  const now = new Date()
150
215
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id)
216
+ // Full-set replace: when the caller provides a profile list it
217
+ // overwrites whatever the runner previously advertised. Omitting
218
+ // the field on a re-registration preserves the existing value.
219
+ const sandboxProfilesValue = input.sandboxProfiles
220
+ ? input.sandboxProfiles.map((p) => ({
221
+ name: p.name,
222
+ label: p.label,
223
+ ...(p.description !== undefined && { description: p.description }),
224
+ ...(p.remote !== undefined && { remote: p.remote }),
225
+ }))
226
+ : undefined
151
227
 
152
228
  await this.db
153
229
  .insert(runners)
@@ -159,6 +235,9 @@ export class PostgresRegistry {
159
235
  kind: input.kind ?? `local`,
160
236
  adminStatus: input.adminStatus ?? `enabled`,
161
237
  wakeStream,
238
+ ...(sandboxProfilesValue !== undefined && {
239
+ sandboxProfiles: sandboxProfilesValue,
240
+ }),
162
241
  updatedAt: now,
163
242
  })
164
243
  .onConflictDoUpdate({
@@ -169,6 +248,9 @@ export class PostgresRegistry {
169
248
  kind: input.kind ?? `local`,
170
249
  adminStatus: input.adminStatus ?? `enabled`,
171
250
  wakeStream,
251
+ ...(sandboxProfilesValue !== undefined && {
252
+ sandboxProfiles: sandboxProfilesValue,
253
+ }),
172
254
  updatedAt: now,
173
255
  },
174
256
  })
@@ -180,6 +262,44 @@ export class PostgresRegistry {
180
262
  return runner
181
263
  }
182
264
 
265
+ /**
266
+ * Every sandbox profile advertised by a runner in this tenant (one entry
267
+ * per runner that advertises it — names may repeat across runners). Used by
268
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
269
+ * is remote (so a shared sandbox can skip the single-runner guard).
270
+ */
271
+ async listSandboxProfiles(): Promise<Array<SandboxProfileAdvertisement>> {
272
+ const rows = await this.db
273
+ .select({ sandboxProfiles: runners.sandboxProfiles })
274
+ .from(runners)
275
+ .where(eq(runners.tenantId, this.tenantId))
276
+ const profiles: Array<SandboxProfileAdvertisement> = []
277
+ for (const row of rows) {
278
+ const list = row.sandboxProfiles as
279
+ | Array<{
280
+ name?: unknown
281
+ label?: unknown
282
+ description?: unknown
283
+ remote?: unknown
284
+ }>
285
+ | null
286
+ | undefined
287
+ if (!Array.isArray(list)) continue
288
+ for (const entry of list) {
289
+ if (!entry || typeof entry.name !== `string`) continue
290
+ profiles.push({
291
+ name: entry.name,
292
+ label: typeof entry.label === `string` ? entry.label : entry.name,
293
+ ...(typeof entry.description === `string` && {
294
+ description: entry.description,
295
+ }),
296
+ ...(typeof entry.remote === `boolean` && { remote: entry.remote }),
297
+ })
298
+ }
299
+ }
300
+ return profiles
301
+ }
302
+
183
303
  async getRunner(id: string): Promise<ElectricAgentsRunner | null> {
184
304
  const rows = await this.db
185
305
  .select()
@@ -613,6 +733,7 @@ export class PostgresRegistry {
613
733
  tags: normalizeTags(entity.tags),
614
734
  tagsIndex: buildTagsIndex(entity.tags),
615
735
  spawnArgs: entity.spawn_args ?? {},
736
+ sandbox: entity.sandbox ?? null,
616
737
  parent: entity.parent ?? null,
617
738
  createdBy: entity.created_by ?? null,
618
739
  typeRevision: entity.type_revision ?? null,
@@ -635,6 +756,67 @@ export class PostgresRegistry {
635
756
  })
636
757
  .onConflictDoNothing()
637
758
 
759
+ await tx
760
+ .insert(entityLineage)
761
+ .values({
762
+ tenantId: this.tenantId,
763
+ ancestorUrl: entity.url,
764
+ descendantUrl: entity.url,
765
+ depth: 0,
766
+ })
767
+ .onConflictDoNothing()
768
+
769
+ if (entity.parent) {
770
+ await tx.execute(sql`
771
+ INSERT INTO ${entityLineage} (
772
+ tenant_id,
773
+ ancestor_url,
774
+ descendant_url,
775
+ depth
776
+ )
777
+ SELECT
778
+ ${this.tenantId},
779
+ ancestor_url,
780
+ ${entity.url},
781
+ depth + 1
782
+ FROM ${entityLineage}
783
+ WHERE tenant_id = ${this.tenantId}
784
+ AND descendant_url = ${entity.parent}
785
+ ON CONFLICT DO NOTHING
786
+ `)
787
+ }
788
+
789
+ await tx.execute(sql`
790
+ INSERT INTO ${entityEffectivePermissions} (
791
+ tenant_id,
792
+ entity_url,
793
+ source_entity_url,
794
+ source_grant_id,
795
+ permission,
796
+ subject_kind,
797
+ subject_value,
798
+ expires_at
799
+ )
800
+ SELECT
801
+ ${this.tenantId},
802
+ ${entity.url},
803
+ grants.entity_url,
804
+ grants.id,
805
+ grants.permission,
806
+ grants.subject_kind,
807
+ grants.subject_value,
808
+ grants.expires_at
809
+ FROM ${entityPermissionGrants} grants
810
+ JOIN ${entityLineage} lineage
811
+ ON lineage.tenant_id = grants.tenant_id
812
+ AND lineage.ancestor_url = grants.entity_url
813
+ AND lineage.descendant_url = ${entity.url}
814
+ WHERE grants.tenant_id = ${this.tenantId}
815
+ AND grants.propagation = 'descendants'
816
+ AND (grants.expires_at IS NULL OR grants.expires_at > now())
817
+ ON CONFLICT DO NOTHING
818
+ `)
819
+
638
820
  return parseInt(result[0]!.txid)
639
821
  })
640
822
  } catch (err) {
@@ -671,12 +853,9 @@ export class PostgresRegistry {
671
853
  streamPath: string
672
854
  ): Promise<ElectricAgentsEntity | null> {
673
855
  const mainSuffix = `/main`
674
- const errorSuffix = `/error`
675
856
  let entityUrl: string | null = null
676
857
  if (streamPath.endsWith(mainSuffix)) {
677
858
  entityUrl = streamPath.slice(0, -mainSuffix.length)
678
- } else if (streamPath.endsWith(errorSuffix)) {
679
- entityUrl = streamPath.slice(0, -errorSuffix.length)
680
859
  }
681
860
  if (!entityUrl) return null
682
861
  return this.getEntity(entityUrl)
@@ -689,6 +868,11 @@ export class PostgresRegistry {
689
868
  limit?: number
690
869
  offset?: number
691
870
  created_by?: string
871
+ readableBy?: {
872
+ principalUrl: string
873
+ principalKind: string
874
+ bypass?: boolean
875
+ }
692
876
  }): Promise<{ entities: Array<ElectricAgentsEntity>; total: number }> {
693
877
  const conditions = [eq(entities.tenantId, this.tenantId)]
694
878
  if (filter?.type) conditions.push(eq(entities.type, filter.type))
@@ -696,6 +880,25 @@ export class PostgresRegistry {
696
880
  if (filter?.parent) conditions.push(eq(entities.parent, filter.parent))
697
881
  if (filter?.created_by)
698
882
  conditions.push(eq(entities.createdBy, filter.created_by))
883
+ if (filter?.readableBy && !filter.readableBy.bypass) {
884
+ conditions.push(sql`(
885
+ ${entities.createdBy} = ${filter.readableBy.principalUrl}
886
+ OR ${entities.url} IN (
887
+ SELECT ${entityEffectivePermissions.entityUrl}
888
+ FROM ${entityEffectivePermissions}
889
+ WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
890
+ AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
891
+ AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
892
+ AND (
893
+ (${entityEffectivePermissions.subjectKind} = 'principal'
894
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
895
+ OR
896
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
897
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
898
+ )
899
+ )
900
+ )`)
901
+ }
699
902
 
700
903
  const whereClause = and(...conditions)
701
904
 
@@ -726,6 +929,431 @@ export class PostgresRegistry {
726
929
  }
727
930
  }
728
931
 
932
+ async createEntityTypePermissionGrant(
933
+ input: CreateEntityTypePermissionGrantInput
934
+ ): Promise<EntityTypePermissionGrant> {
935
+ const [row] = await this.db
936
+ .insert(entityTypePermissionGrants)
937
+ .values({
938
+ tenantId: this.tenantId,
939
+ entityType: input.entityType,
940
+ permission: input.permission,
941
+ subjectKind: input.subjectKind,
942
+ subjectValue: input.subjectValue,
943
+ createdBy: input.createdBy ?? null,
944
+ expiresAt: input.expiresAt ?? null,
945
+ })
946
+ .returning()
947
+ return this.rowToEntityTypePermissionGrant(row!)
948
+ }
949
+
950
+ async ensureEntityTypePermissionGrant(
951
+ input: CreateEntityTypePermissionGrantInput
952
+ ): Promise<EntityTypePermissionGrant> {
953
+ const [existing] = await this.db
954
+ .select()
955
+ .from(entityTypePermissionGrants)
956
+ .where(
957
+ and(
958
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
959
+ eq(entityTypePermissionGrants.entityType, input.entityType),
960
+ eq(entityTypePermissionGrants.permission, input.permission),
961
+ eq(entityTypePermissionGrants.subjectKind, input.subjectKind),
962
+ eq(entityTypePermissionGrants.subjectValue, input.subjectValue),
963
+ input.expiresAt
964
+ ? eq(entityTypePermissionGrants.expiresAt, input.expiresAt)
965
+ : sql`${entityTypePermissionGrants.expiresAt} IS NULL`
966
+ )
967
+ )
968
+ .limit(1)
969
+ if (existing) return this.rowToEntityTypePermissionGrant(existing)
970
+
971
+ return await this.createEntityTypePermissionGrant(input)
972
+ }
973
+
974
+ async listEntityTypePermissionGrants(
975
+ entityType: string
976
+ ): Promise<Array<EntityTypePermissionGrant>> {
977
+ const rows = await this.db
978
+ .select()
979
+ .from(entityTypePermissionGrants)
980
+ .where(
981
+ and(
982
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
983
+ eq(entityTypePermissionGrants.entityType, entityType)
984
+ )
985
+ )
986
+ .orderBy(entityTypePermissionGrants.id)
987
+ return rows.map((row) => this.rowToEntityTypePermissionGrant(row))
988
+ }
989
+
990
+ async deleteEntityTypePermissionGrant(
991
+ entityType: string,
992
+ grantId: number
993
+ ): Promise<boolean> {
994
+ const rows = await this.db
995
+ .delete(entityTypePermissionGrants)
996
+ .where(
997
+ and(
998
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
999
+ eq(entityTypePermissionGrants.entityType, entityType),
1000
+ eq(entityTypePermissionGrants.id, grantId)
1001
+ )
1002
+ )
1003
+ .returning({ id: entityTypePermissionGrants.id })
1004
+ return rows.length > 0
1005
+ }
1006
+
1007
+ async hasEntityTypePermission(
1008
+ entityType: string,
1009
+ permission: EntityTypePermission,
1010
+ subject: { principalUrl: string; principalKind: string }
1011
+ ): Promise<boolean> {
1012
+ const permissions = [permission, `manage`] as const
1013
+ const rows = await this.db
1014
+ .select({ id: entityTypePermissionGrants.id })
1015
+ .from(entityTypePermissionGrants)
1016
+ .where(
1017
+ and(
1018
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1019
+ eq(entityTypePermissionGrants.entityType, entityType),
1020
+ inArray(entityTypePermissionGrants.permission, [...permissions]),
1021
+ sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`,
1022
+ sql`(
1023
+ (${entityTypePermissionGrants.subjectKind} = 'principal'
1024
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
1025
+ OR
1026
+ (${entityTypePermissionGrants.subjectKind} = 'principal_kind'
1027
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
1028
+ )`
1029
+ )
1030
+ )
1031
+ .limit(1)
1032
+ return rows.length > 0
1033
+ }
1034
+
1035
+ async createEntityPermissionGrant(
1036
+ input: CreateEntityPermissionGrantInput
1037
+ ): Promise<EntityPermissionGrant> {
1038
+ return await this.db.transaction(async (tx) => {
1039
+ const [row] = await tx
1040
+ .insert(entityPermissionGrants)
1041
+ .values({
1042
+ tenantId: this.tenantId,
1043
+ entityUrl: input.entityUrl,
1044
+ permission: input.permission,
1045
+ subjectKind: input.subjectKind,
1046
+ subjectValue: input.subjectValue,
1047
+ propagation: input.propagation ?? `self`,
1048
+ copyToChildren: input.copyToChildren ?? false,
1049
+ createdBy: input.createdBy ?? null,
1050
+ expiresAt: input.expiresAt ?? null,
1051
+ })
1052
+ .returning()
1053
+ await this.materializeEntityPermissionGrant(tx, row!)
1054
+ return this.rowToEntityPermissionGrant(row!)
1055
+ })
1056
+ }
1057
+
1058
+ async listEntityPermissionGrants(
1059
+ entityUrl: string
1060
+ ): Promise<Array<EntityPermissionGrant>> {
1061
+ const rows = await this.db
1062
+ .select()
1063
+ .from(entityPermissionGrants)
1064
+ .where(
1065
+ and(
1066
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1067
+ eq(entityPermissionGrants.entityUrl, entityUrl)
1068
+ )
1069
+ )
1070
+ .orderBy(entityPermissionGrants.id)
1071
+ return rows.map((row) => this.rowToEntityPermissionGrant(row))
1072
+ }
1073
+
1074
+ async deleteEntityPermissionGrant(
1075
+ entityUrl: string,
1076
+ grantId: number
1077
+ ): Promise<boolean> {
1078
+ return await this.db.transaction(async (tx) => {
1079
+ await tx
1080
+ .delete(entityEffectivePermissions)
1081
+ .where(
1082
+ and(
1083
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1084
+ eq(entityEffectivePermissions.sourceGrantId, grantId)
1085
+ )
1086
+ )
1087
+ const rows = await tx
1088
+ .delete(entityPermissionGrants)
1089
+ .where(
1090
+ and(
1091
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1092
+ eq(entityPermissionGrants.entityUrl, entityUrl),
1093
+ eq(entityPermissionGrants.id, grantId)
1094
+ )
1095
+ )
1096
+ .returning({ id: entityPermissionGrants.id })
1097
+ return rows.length > 0
1098
+ })
1099
+ }
1100
+
1101
+ async copyEntityPermissionGrantsForSpawn(
1102
+ parentEntityUrl: string,
1103
+ childEntityUrl: string,
1104
+ createdBy?: string
1105
+ ): Promise<Array<EntityPermissionGrant>> {
1106
+ const parentGrants = await this.db
1107
+ .select()
1108
+ .from(entityPermissionGrants)
1109
+ .where(
1110
+ and(
1111
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1112
+ eq(entityPermissionGrants.entityUrl, parentEntityUrl),
1113
+ eq(entityPermissionGrants.copyToChildren, true),
1114
+ sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`
1115
+ )
1116
+ )
1117
+
1118
+ const copied: Array<EntityPermissionGrant> = []
1119
+ for (const grant of parentGrants) {
1120
+ copied.push(
1121
+ await this.createEntityPermissionGrant({
1122
+ entityUrl: childEntityUrl,
1123
+ permission: grant.permission as EntityPermission,
1124
+ subjectKind: grant.subjectKind as PermissionSubjectKind,
1125
+ subjectValue: grant.subjectValue,
1126
+ propagation: `self`,
1127
+ copyToChildren: grant.copyToChildren,
1128
+ createdBy,
1129
+ expiresAt: grant.expiresAt ?? undefined,
1130
+ })
1131
+ )
1132
+ }
1133
+ return copied
1134
+ }
1135
+
1136
+ async hasEntityPermission(
1137
+ entityUrl: string,
1138
+ permission: EntityPermission,
1139
+ subject: { principalUrl: string; principalKind: string }
1140
+ ): Promise<boolean> {
1141
+ const permissions = [permission, `manage`] as const
1142
+ const rows = await this.db
1143
+ .select({ id: entityEffectivePermissions.id })
1144
+ .from(entityEffectivePermissions)
1145
+ .where(
1146
+ and(
1147
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1148
+ eq(entityEffectivePermissions.entityUrl, entityUrl),
1149
+ inArray(entityEffectivePermissions.permission, [...permissions]),
1150
+ sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`,
1151
+ sql`(
1152
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1153
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
1154
+ OR
1155
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1156
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
1157
+ )`
1158
+ )
1159
+ )
1160
+ .limit(1)
1161
+ return rows.length > 0
1162
+ }
1163
+
1164
+ async replaceSharedStateLink(
1165
+ ownerEntityUrl: string,
1166
+ manifestKey: string,
1167
+ sharedStateId?: string
1168
+ ): Promise<void> {
1169
+ await this.db
1170
+ .delete(sharedStateLinks)
1171
+ .where(
1172
+ and(
1173
+ eq(sharedStateLinks.tenantId, this.tenantId),
1174
+ eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl),
1175
+ eq(sharedStateLinks.manifestKey, manifestKey)
1176
+ )
1177
+ )
1178
+
1179
+ if (!sharedStateId) return
1180
+
1181
+ await this.db
1182
+ .insert(sharedStateLinks)
1183
+ .values({
1184
+ tenantId: this.tenantId,
1185
+ ownerEntityUrl,
1186
+ manifestKey,
1187
+ sharedStateId,
1188
+ })
1189
+ .onConflictDoUpdate({
1190
+ target: [
1191
+ sharedStateLinks.tenantId,
1192
+ sharedStateLinks.ownerEntityUrl,
1193
+ sharedStateLinks.manifestKey,
1194
+ ],
1195
+ set: {
1196
+ sharedStateId,
1197
+ updatedAt: new Date(),
1198
+ },
1199
+ })
1200
+ }
1201
+
1202
+ async listSharedStateLinkedEntityUrls(
1203
+ sharedStateId: string
1204
+ ): Promise<Array<string>> {
1205
+ const rows = await this.db
1206
+ .selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl })
1207
+ .from(sharedStateLinks)
1208
+ .where(
1209
+ and(
1210
+ eq(sharedStateLinks.tenantId, this.tenantId),
1211
+ eq(sharedStateLinks.sharedStateId, sharedStateId)
1212
+ )
1213
+ )
1214
+ return rows.map((row) => row.ownerEntityUrl)
1215
+ }
1216
+
1217
+ async pruneExpiredPermissionGrants(
1218
+ now: Date = new Date(),
1219
+ options: { force?: boolean } = {}
1220
+ ): Promise<void> {
1221
+ if (this.permissionPrunePromise) return await this.permissionPrunePromise
1222
+
1223
+ const startedAt = Date.now()
1224
+ if (
1225
+ !options.force &&
1226
+ startedAt - this.lastPermissionPruneStartedAt <
1227
+ PERMISSION_PRUNE_INTERVAL_MS
1228
+ ) {
1229
+ return
1230
+ }
1231
+
1232
+ this.lastPermissionPruneStartedAt = startedAt
1233
+ const promise = this.pruneExpiredPermissionGrantsNow(now)
1234
+ this.permissionPrunePromise = promise
1235
+ try {
1236
+ await promise
1237
+ } catch (error) {
1238
+ this.lastPermissionPruneStartedAt = 0
1239
+ throw error
1240
+ } finally {
1241
+ if (this.permissionPrunePromise === promise) {
1242
+ this.permissionPrunePromise = null
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ private async pruneExpiredPermissionGrantsNow(now: Date): Promise<void> {
1248
+ await this.db.transaction(async (tx) => {
1249
+ const expiredEntityGrantIds = await tx
1250
+ .select({ id: entityPermissionGrants.id })
1251
+ .from(entityPermissionGrants)
1252
+ .where(
1253
+ and(
1254
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1255
+ sql`${entityPermissionGrants.expiresAt} IS NOT NULL`,
1256
+ lt(entityPermissionGrants.expiresAt, now)
1257
+ )
1258
+ )
1259
+ const ids = expiredEntityGrantIds.map((row) => row.id)
1260
+ if (ids.length > 0) {
1261
+ await tx
1262
+ .delete(entityEffectivePermissions)
1263
+ .where(
1264
+ and(
1265
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1266
+ inArray(entityEffectivePermissions.sourceGrantId, ids)
1267
+ )
1268
+ )
1269
+ await tx
1270
+ .delete(entityPermissionGrants)
1271
+ .where(
1272
+ and(
1273
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1274
+ inArray(entityPermissionGrants.id, ids)
1275
+ )
1276
+ )
1277
+ }
1278
+
1279
+ await tx
1280
+ .delete(entityEffectivePermissions)
1281
+ .where(
1282
+ and(
1283
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1284
+ sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`,
1285
+ lt(entityEffectivePermissions.expiresAt, now)
1286
+ )
1287
+ )
1288
+ await tx
1289
+ .delete(entityTypePermissionGrants)
1290
+ .where(
1291
+ and(
1292
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1293
+ sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`,
1294
+ lt(entityTypePermissionGrants.expiresAt, now)
1295
+ )
1296
+ )
1297
+ })
1298
+ }
1299
+
1300
+ private async materializeEntityPermissionGrant(
1301
+ tx: RegistryTransaction,
1302
+ grant: typeof entityPermissionGrants.$inferSelect
1303
+ ): Promise<void> {
1304
+ await tx
1305
+ .delete(entityEffectivePermissions)
1306
+ .where(
1307
+ and(
1308
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1309
+ eq(entityEffectivePermissions.sourceGrantId, grant.id)
1310
+ )
1311
+ )
1312
+
1313
+ if (grant.propagation === `descendants`) {
1314
+ await tx.execute(sql`
1315
+ INSERT INTO ${entityEffectivePermissions} (
1316
+ tenant_id,
1317
+ entity_url,
1318
+ source_entity_url,
1319
+ source_grant_id,
1320
+ permission,
1321
+ subject_kind,
1322
+ subject_value,
1323
+ expires_at
1324
+ )
1325
+ SELECT
1326
+ ${this.tenantId},
1327
+ descendant_url,
1328
+ ${grant.entityUrl},
1329
+ ${grant.id},
1330
+ ${grant.permission},
1331
+ ${grant.subjectKind},
1332
+ ${grant.subjectValue},
1333
+ ${grant.expiresAt}
1334
+ FROM ${entityLineage}
1335
+ WHERE tenant_id = ${this.tenantId}
1336
+ AND ancestor_url = ${grant.entityUrl}
1337
+ ON CONFLICT DO NOTHING
1338
+ `)
1339
+ return
1340
+ }
1341
+
1342
+ await tx
1343
+ .insert(entityEffectivePermissions)
1344
+ .values({
1345
+ tenantId: this.tenantId,
1346
+ entityUrl: grant.entityUrl,
1347
+ sourceEntityUrl: grant.entityUrl,
1348
+ sourceGrantId: grant.id,
1349
+ permission: grant.permission,
1350
+ subjectKind: grant.subjectKind,
1351
+ subjectValue: grant.subjectValue,
1352
+ expiresAt: grant.expiresAt,
1353
+ })
1354
+ .onConflictDoNothing()
1355
+ }
1356
+
729
1357
  async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
730
1358
  const whereClause = isTerminalEntityStatus(status)
731
1359
  ? this.entityWhere(entityUrl)
@@ -889,6 +1517,8 @@ export class PostgresRegistry {
889
1517
  sourceRef: string
890
1518
  tags: EntityTags
891
1519
  streamUrl: string
1520
+ principalUrl: string
1521
+ principalKind: string
892
1522
  }): Promise<EntityBridgeRow> {
893
1523
  await this.db
894
1524
  .insert(entityBridges)
@@ -897,6 +1527,8 @@ export class PostgresRegistry {
897
1527
  sourceRef: row.sourceRef,
898
1528
  tags: normalizeTags(row.tags),
899
1529
  streamUrl: row.streamUrl,
1530
+ principalUrl: row.principalUrl,
1531
+ principalKind: row.principalKind,
900
1532
  })
901
1533
  .onConflictDoNothing()
902
1534
 
@@ -1207,6 +1839,40 @@ export class PostgresRegistry {
1207
1839
  }
1208
1840
  }
1209
1841
 
1842
+ private rowToEntityTypePermissionGrant(
1843
+ row: typeof entityTypePermissionGrants.$inferSelect
1844
+ ): EntityTypePermissionGrant {
1845
+ return {
1846
+ id: row.id,
1847
+ entity_type: row.entityType,
1848
+ permission: row.permission as EntityTypePermission,
1849
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1850
+ subject_value: row.subjectValue,
1851
+ created_by: row.createdBy ?? undefined,
1852
+ expires_at: row.expiresAt?.toISOString(),
1853
+ created_at: row.createdAt.toISOString(),
1854
+ updated_at: row.updatedAt.toISOString(),
1855
+ }
1856
+ }
1857
+
1858
+ private rowToEntityPermissionGrant(
1859
+ row: typeof entityPermissionGrants.$inferSelect
1860
+ ): EntityPermissionGrant {
1861
+ return {
1862
+ id: row.id,
1863
+ entity_url: row.entityUrl,
1864
+ permission: row.permission as EntityPermission,
1865
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1866
+ subject_value: row.subjectValue,
1867
+ propagation: row.propagation as EntityPermissionPropagation,
1868
+ copy_to_children: row.copyToChildren,
1869
+ created_by: row.createdBy ?? undefined,
1870
+ expires_at: row.expiresAt?.toISOString(),
1871
+ created_at: row.createdAt.toISOString(),
1872
+ updated_at: row.updatedAt.toISOString(),
1873
+ }
1874
+ }
1875
+
1210
1876
  private rowToEntity(row: typeof entities.$inferSelect): ElectricAgentsEntity {
1211
1877
  return {
1212
1878
  url: row.url,
@@ -1214,7 +1880,6 @@ export class PostgresRegistry {
1214
1880
  status: assertEntityStatus(row.status),
1215
1881
  streams: {
1216
1882
  main: `${row.url}/main`,
1217
- error: `${row.url}/error`,
1218
1883
  },
1219
1884
  subscription_id: row.subscriptionId,
1220
1885
  dispatch_policy:
@@ -1223,6 +1888,8 @@ export class PostgresRegistry {
1223
1888
  write_token: row.writeToken,
1224
1889
  tags: (row.tags as EntityTags | null | undefined) ?? {},
1225
1890
  spawn_args: row.spawnArgs as Record<string, unknown> | undefined,
1891
+ sandbox:
1892
+ (row.sandbox as ElectricAgentsEntity[`sandbox`] | null) ?? undefined,
1226
1893
  parent: row.parent ?? undefined,
1227
1894
  created_by: row.createdBy ?? undefined,
1228
1895
  type_revision: row.typeRevision ?? undefined,
@@ -1245,6 +1912,8 @@ export class PostgresRegistry {
1245
1912
  sourceRef: row.sourceRef,
1246
1913
  tags: (row.tags as EntityTags | null | undefined) ?? {},
1247
1914
  streamUrl: row.streamUrl,
1915
+ principalUrl: row.principalUrl ?? undefined,
1916
+ principalKind: row.principalKind ?? undefined,
1248
1917
  shapeHandle: row.shapeHandle ?? undefined,
1249
1918
  shapeOffset: row.shapeOffset ?? undefined,
1250
1919
  lastObserverActivityAt: row.lastObserverActivityAt,
@@ -1295,6 +1964,13 @@ export class PostgresRegistry {
1295
1964
  kind: assertRunnerKind(row.kind),
1296
1965
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1297
1966
  wake_stream: row.wakeStream,
1967
+ sandbox_profiles:
1968
+ (row.sandboxProfiles as Array<{
1969
+ name: string
1970
+ label: string
1971
+ description?: string
1972
+ remote?: boolean
1973
+ }> | null) ?? [],
1298
1974
  created_at: row.createdAt.toISOString(),
1299
1975
  updated_at: row.updatedAt.toISOString(),
1300
1976
  }