@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.
@@ -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,
@@ -30,8 +36,15 @@ import type {
30
36
  SourceStreamOffset,
31
37
  ConsumerClaim,
32
38
  DispatchPolicy,
39
+ EntityPermission,
40
+ EntityPermissionGrant,
41
+ EntityPermissionPropagation,
42
+ EntityTypePermission,
43
+ EntityTypePermissionGrant,
44
+ PermissionSubjectKind,
33
45
  } from './electric-agents-types.js'
34
46
  import type { EntityTags } from '@electric-ax/agents-runtime'
47
+ import type { Principal } from './principal.js'
35
48
 
36
49
  export class EntityAlreadyExistsError extends Error {
37
50
  constructor(public readonly url: string) {
@@ -51,6 +64,8 @@ export interface EntityBridgeRow {
51
64
  sourceRef: string
52
65
  tags: EntityTags
53
66
  streamUrl: string
67
+ principalUrl?: string
68
+ principalKind?: string
54
69
  shapeHandle?: string
55
70
  shapeOffset?: string
56
71
  lastObserverActivityAt: Date
@@ -135,13 +150,43 @@ export interface MaterializeReleasedClaimInput {
135
150
  releasedAt?: Date
136
151
  }
137
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
+
138
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]
139
179
 
140
180
  export function runnerWakeStream(runnerId: string): string {
141
181
  return `/runners/${runnerId}/wake`
142
182
  }
143
183
 
144
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
+
145
190
  constructor(
146
191
  private db: DrizzleDB,
147
192
  readonly tenantId: string = DEFAULT_TENANT_ID
@@ -151,6 +196,18 @@ export class PostgresRegistry {
151
196
 
152
197
  close(): void {}
153
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
+
154
211
  async createRunner(
155
212
  input: RegisterRunnerInput
156
213
  ): Promise<ElectricAgentsRunner> {
@@ -576,6 +633,7 @@ export class PostgresRegistry {
576
633
  creationSchema: et.creation_schema ?? null,
577
634
  inboxSchemas: et.inbox_schemas ?? null,
578
635
  stateSchemas: et.state_schemas ?? null,
636
+ slashCommands: et.slash_commands ?? null,
579
637
  serveEndpoint: et.serve_endpoint ?? null,
580
638
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
581
639
  revision: et.revision,
@@ -589,6 +647,7 @@ export class PostgresRegistry {
589
647
  creationSchema: et.creation_schema ?? null,
590
648
  inboxSchemas: et.inbox_schemas ?? null,
591
649
  stateSchemas: et.state_schemas ?? null,
650
+ slashCommands: et.slash_commands ?? null,
592
651
  serveEndpoint: et.serve_endpoint ?? null,
593
652
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
594
653
  revision: et.revision,
@@ -611,6 +670,7 @@ export class PostgresRegistry {
611
670
  creationSchema: et.creation_schema ?? null,
612
671
  inboxSchemas: et.inbox_schemas ?? null,
613
672
  stateSchemas: et.state_schemas ?? null,
673
+ slashCommands: et.slash_commands ?? null,
614
674
  serveEndpoint: et.serve_endpoint ?? null,
615
675
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
616
676
  revision: et.revision,
@@ -652,6 +712,7 @@ export class PostgresRegistry {
652
712
  creationSchema: et.creation_schema ?? null,
653
713
  inboxSchemas: et.inbox_schemas ?? null,
654
714
  stateSchemas: et.state_schemas ?? null,
715
+ slashCommands: et.slash_commands ?? null,
655
716
  serveEndpoint: et.serve_endpoint ?? null,
656
717
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
657
718
  revision: et.revision,
@@ -699,6 +760,67 @@ export class PostgresRegistry {
699
760
  })
700
761
  .onConflictDoNothing()
701
762
 
763
+ await tx
764
+ .insert(entityLineage)
765
+ .values({
766
+ tenantId: this.tenantId,
767
+ ancestorUrl: entity.url,
768
+ descendantUrl: entity.url,
769
+ depth: 0,
770
+ })
771
+ .onConflictDoNothing()
772
+
773
+ if (entity.parent) {
774
+ await tx.execute(sql`
775
+ INSERT INTO ${entityLineage} (
776
+ tenant_id,
777
+ ancestor_url,
778
+ descendant_url,
779
+ depth
780
+ )
781
+ SELECT
782
+ ${this.tenantId},
783
+ ancestor_url,
784
+ ${entity.url},
785
+ depth + 1
786
+ FROM ${entityLineage}
787
+ WHERE tenant_id = ${this.tenantId}
788
+ AND descendant_url = ${entity.parent}
789
+ ON CONFLICT DO NOTHING
790
+ `)
791
+ }
792
+
793
+ await tx.execute(sql`
794
+ INSERT INTO ${entityEffectivePermissions} (
795
+ tenant_id,
796
+ entity_url,
797
+ source_entity_url,
798
+ source_grant_id,
799
+ permission,
800
+ subject_kind,
801
+ subject_value,
802
+ expires_at
803
+ )
804
+ SELECT
805
+ ${this.tenantId},
806
+ ${entity.url},
807
+ grants.entity_url,
808
+ grants.id,
809
+ grants.permission,
810
+ grants.subject_kind,
811
+ grants.subject_value,
812
+ grants.expires_at
813
+ FROM ${entityPermissionGrants} grants
814
+ JOIN ${entityLineage} lineage
815
+ ON lineage.tenant_id = grants.tenant_id
816
+ AND lineage.ancestor_url = grants.entity_url
817
+ AND lineage.descendant_url = ${entity.url}
818
+ WHERE grants.tenant_id = ${this.tenantId}
819
+ AND grants.propagation = 'descendants'
820
+ AND (grants.expires_at IS NULL OR grants.expires_at > now())
821
+ ON CONFLICT DO NOTHING
822
+ `)
823
+
702
824
  return parseInt(result[0]!.txid)
703
825
  })
704
826
  } catch (err) {
@@ -735,12 +857,9 @@ export class PostgresRegistry {
735
857
  streamPath: string
736
858
  ): Promise<ElectricAgentsEntity | null> {
737
859
  const mainSuffix = `/main`
738
- const errorSuffix = `/error`
739
860
  let entityUrl: string | null = null
740
861
  if (streamPath.endsWith(mainSuffix)) {
741
862
  entityUrl = streamPath.slice(0, -mainSuffix.length)
742
- } else if (streamPath.endsWith(errorSuffix)) {
743
- entityUrl = streamPath.slice(0, -errorSuffix.length)
744
863
  }
745
864
  if (!entityUrl) return null
746
865
  return this.getEntity(entityUrl)
@@ -753,6 +872,11 @@ export class PostgresRegistry {
753
872
  limit?: number
754
873
  offset?: number
755
874
  created_by?: string
875
+ readableBy?: {
876
+ principalUrl: string
877
+ principalKind: string
878
+ bypass?: boolean
879
+ }
756
880
  }): Promise<{ entities: Array<ElectricAgentsEntity>; total: number }> {
757
881
  const conditions = [eq(entities.tenantId, this.tenantId)]
758
882
  if (filter?.type) conditions.push(eq(entities.type, filter.type))
@@ -760,6 +884,25 @@ export class PostgresRegistry {
760
884
  if (filter?.parent) conditions.push(eq(entities.parent, filter.parent))
761
885
  if (filter?.created_by)
762
886
  conditions.push(eq(entities.createdBy, filter.created_by))
887
+ if (filter?.readableBy && !filter.readableBy.bypass) {
888
+ conditions.push(sql`(
889
+ ${entities.createdBy} = ${filter.readableBy.principalUrl}
890
+ OR ${entities.url} IN (
891
+ SELECT ${entityEffectivePermissions.entityUrl}
892
+ FROM ${entityEffectivePermissions}
893
+ WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
894
+ AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
895
+ AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
896
+ AND (
897
+ (${entityEffectivePermissions.subjectKind} = 'principal'
898
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
899
+ OR
900
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
901
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
902
+ )
903
+ )
904
+ )`)
905
+ }
763
906
 
764
907
  const whereClause = and(...conditions)
765
908
 
@@ -790,6 +933,431 @@ export class PostgresRegistry {
790
933
  }
791
934
  }
792
935
 
936
+ async createEntityTypePermissionGrant(
937
+ input: CreateEntityTypePermissionGrantInput
938
+ ): Promise<EntityTypePermissionGrant> {
939
+ const [row] = await this.db
940
+ .insert(entityTypePermissionGrants)
941
+ .values({
942
+ tenantId: this.tenantId,
943
+ entityType: input.entityType,
944
+ permission: input.permission,
945
+ subjectKind: input.subjectKind,
946
+ subjectValue: input.subjectValue,
947
+ createdBy: input.createdBy ?? null,
948
+ expiresAt: input.expiresAt ?? null,
949
+ })
950
+ .returning()
951
+ return this.rowToEntityTypePermissionGrant(row!)
952
+ }
953
+
954
+ async ensureEntityTypePermissionGrant(
955
+ input: CreateEntityTypePermissionGrantInput
956
+ ): Promise<EntityTypePermissionGrant> {
957
+ const [existing] = await this.db
958
+ .select()
959
+ .from(entityTypePermissionGrants)
960
+ .where(
961
+ and(
962
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
963
+ eq(entityTypePermissionGrants.entityType, input.entityType),
964
+ eq(entityTypePermissionGrants.permission, input.permission),
965
+ eq(entityTypePermissionGrants.subjectKind, input.subjectKind),
966
+ eq(entityTypePermissionGrants.subjectValue, input.subjectValue),
967
+ input.expiresAt
968
+ ? eq(entityTypePermissionGrants.expiresAt, input.expiresAt)
969
+ : sql`${entityTypePermissionGrants.expiresAt} IS NULL`
970
+ )
971
+ )
972
+ .limit(1)
973
+ if (existing) return this.rowToEntityTypePermissionGrant(existing)
974
+
975
+ return await this.createEntityTypePermissionGrant(input)
976
+ }
977
+
978
+ async listEntityTypePermissionGrants(
979
+ entityType: string
980
+ ): Promise<Array<EntityTypePermissionGrant>> {
981
+ const rows = await this.db
982
+ .select()
983
+ .from(entityTypePermissionGrants)
984
+ .where(
985
+ and(
986
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
987
+ eq(entityTypePermissionGrants.entityType, entityType)
988
+ )
989
+ )
990
+ .orderBy(entityTypePermissionGrants.id)
991
+ return rows.map((row) => this.rowToEntityTypePermissionGrant(row))
992
+ }
993
+
994
+ async deleteEntityTypePermissionGrant(
995
+ entityType: string,
996
+ grantId: number
997
+ ): Promise<boolean> {
998
+ const rows = await this.db
999
+ .delete(entityTypePermissionGrants)
1000
+ .where(
1001
+ and(
1002
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1003
+ eq(entityTypePermissionGrants.entityType, entityType),
1004
+ eq(entityTypePermissionGrants.id, grantId)
1005
+ )
1006
+ )
1007
+ .returning({ id: entityTypePermissionGrants.id })
1008
+ return rows.length > 0
1009
+ }
1010
+
1011
+ async hasEntityTypePermission(
1012
+ entityType: string,
1013
+ permission: EntityTypePermission,
1014
+ subject: { principalUrl: string; principalKind: string }
1015
+ ): Promise<boolean> {
1016
+ const permissions = [permission, `manage`] as const
1017
+ const rows = await this.db
1018
+ .select({ id: entityTypePermissionGrants.id })
1019
+ .from(entityTypePermissionGrants)
1020
+ .where(
1021
+ and(
1022
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1023
+ eq(entityTypePermissionGrants.entityType, entityType),
1024
+ inArray(entityTypePermissionGrants.permission, [...permissions]),
1025
+ sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`,
1026
+ sql`(
1027
+ (${entityTypePermissionGrants.subjectKind} = 'principal'
1028
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
1029
+ OR
1030
+ (${entityTypePermissionGrants.subjectKind} = 'principal_kind'
1031
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
1032
+ )`
1033
+ )
1034
+ )
1035
+ .limit(1)
1036
+ return rows.length > 0
1037
+ }
1038
+
1039
+ async createEntityPermissionGrant(
1040
+ input: CreateEntityPermissionGrantInput
1041
+ ): Promise<EntityPermissionGrant> {
1042
+ return await this.db.transaction(async (tx) => {
1043
+ const [row] = await tx
1044
+ .insert(entityPermissionGrants)
1045
+ .values({
1046
+ tenantId: this.tenantId,
1047
+ entityUrl: input.entityUrl,
1048
+ permission: input.permission,
1049
+ subjectKind: input.subjectKind,
1050
+ subjectValue: input.subjectValue,
1051
+ propagation: input.propagation ?? `self`,
1052
+ copyToChildren: input.copyToChildren ?? false,
1053
+ createdBy: input.createdBy ?? null,
1054
+ expiresAt: input.expiresAt ?? null,
1055
+ })
1056
+ .returning()
1057
+ await this.materializeEntityPermissionGrant(tx, row!)
1058
+ return this.rowToEntityPermissionGrant(row!)
1059
+ })
1060
+ }
1061
+
1062
+ async listEntityPermissionGrants(
1063
+ entityUrl: string
1064
+ ): Promise<Array<EntityPermissionGrant>> {
1065
+ const rows = await this.db
1066
+ .select()
1067
+ .from(entityPermissionGrants)
1068
+ .where(
1069
+ and(
1070
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1071
+ eq(entityPermissionGrants.entityUrl, entityUrl)
1072
+ )
1073
+ )
1074
+ .orderBy(entityPermissionGrants.id)
1075
+ return rows.map((row) => this.rowToEntityPermissionGrant(row))
1076
+ }
1077
+
1078
+ async deleteEntityPermissionGrant(
1079
+ entityUrl: string,
1080
+ grantId: number
1081
+ ): Promise<boolean> {
1082
+ return await this.db.transaction(async (tx) => {
1083
+ await tx
1084
+ .delete(entityEffectivePermissions)
1085
+ .where(
1086
+ and(
1087
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1088
+ eq(entityEffectivePermissions.sourceGrantId, grantId)
1089
+ )
1090
+ )
1091
+ const rows = await tx
1092
+ .delete(entityPermissionGrants)
1093
+ .where(
1094
+ and(
1095
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1096
+ eq(entityPermissionGrants.entityUrl, entityUrl),
1097
+ eq(entityPermissionGrants.id, grantId)
1098
+ )
1099
+ )
1100
+ .returning({ id: entityPermissionGrants.id })
1101
+ return rows.length > 0
1102
+ })
1103
+ }
1104
+
1105
+ async copyEntityPermissionGrantsForSpawn(
1106
+ parentEntityUrl: string,
1107
+ childEntityUrl: string,
1108
+ createdBy?: string
1109
+ ): Promise<Array<EntityPermissionGrant>> {
1110
+ const parentGrants = await this.db
1111
+ .select()
1112
+ .from(entityPermissionGrants)
1113
+ .where(
1114
+ and(
1115
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1116
+ eq(entityPermissionGrants.entityUrl, parentEntityUrl),
1117
+ eq(entityPermissionGrants.copyToChildren, true),
1118
+ sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`
1119
+ )
1120
+ )
1121
+
1122
+ const copied: Array<EntityPermissionGrant> = []
1123
+ for (const grant of parentGrants) {
1124
+ copied.push(
1125
+ await this.createEntityPermissionGrant({
1126
+ entityUrl: childEntityUrl,
1127
+ permission: grant.permission as EntityPermission,
1128
+ subjectKind: grant.subjectKind as PermissionSubjectKind,
1129
+ subjectValue: grant.subjectValue,
1130
+ propagation: `self`,
1131
+ copyToChildren: grant.copyToChildren,
1132
+ createdBy,
1133
+ expiresAt: grant.expiresAt ?? undefined,
1134
+ })
1135
+ )
1136
+ }
1137
+ return copied
1138
+ }
1139
+
1140
+ async hasEntityPermission(
1141
+ entityUrl: string,
1142
+ permission: EntityPermission,
1143
+ subject: { principalUrl: string; principalKind: string }
1144
+ ): Promise<boolean> {
1145
+ const permissions = [permission, `manage`] as const
1146
+ const rows = await this.db
1147
+ .select({ id: entityEffectivePermissions.id })
1148
+ .from(entityEffectivePermissions)
1149
+ .where(
1150
+ and(
1151
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1152
+ eq(entityEffectivePermissions.entityUrl, entityUrl),
1153
+ inArray(entityEffectivePermissions.permission, [...permissions]),
1154
+ sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`,
1155
+ sql`(
1156
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1157
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
1158
+ OR
1159
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1160
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
1161
+ )`
1162
+ )
1163
+ )
1164
+ .limit(1)
1165
+ return rows.length > 0
1166
+ }
1167
+
1168
+ async replaceSharedStateLink(
1169
+ ownerEntityUrl: string,
1170
+ manifestKey: string,
1171
+ sharedStateId?: string
1172
+ ): Promise<void> {
1173
+ await this.db
1174
+ .delete(sharedStateLinks)
1175
+ .where(
1176
+ and(
1177
+ eq(sharedStateLinks.tenantId, this.tenantId),
1178
+ eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl),
1179
+ eq(sharedStateLinks.manifestKey, manifestKey)
1180
+ )
1181
+ )
1182
+
1183
+ if (!sharedStateId) return
1184
+
1185
+ await this.db
1186
+ .insert(sharedStateLinks)
1187
+ .values({
1188
+ tenantId: this.tenantId,
1189
+ ownerEntityUrl,
1190
+ manifestKey,
1191
+ sharedStateId,
1192
+ })
1193
+ .onConflictDoUpdate({
1194
+ target: [
1195
+ sharedStateLinks.tenantId,
1196
+ sharedStateLinks.ownerEntityUrl,
1197
+ sharedStateLinks.manifestKey,
1198
+ ],
1199
+ set: {
1200
+ sharedStateId,
1201
+ updatedAt: new Date(),
1202
+ },
1203
+ })
1204
+ }
1205
+
1206
+ async listSharedStateLinkedEntityUrls(
1207
+ sharedStateId: string
1208
+ ): Promise<Array<string>> {
1209
+ const rows = await this.db
1210
+ .selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl })
1211
+ .from(sharedStateLinks)
1212
+ .where(
1213
+ and(
1214
+ eq(sharedStateLinks.tenantId, this.tenantId),
1215
+ eq(sharedStateLinks.sharedStateId, sharedStateId)
1216
+ )
1217
+ )
1218
+ return rows.map((row) => row.ownerEntityUrl)
1219
+ }
1220
+
1221
+ async pruneExpiredPermissionGrants(
1222
+ now: Date = new Date(),
1223
+ options: { force?: boolean } = {}
1224
+ ): Promise<void> {
1225
+ if (this.permissionPrunePromise) return await this.permissionPrunePromise
1226
+
1227
+ const startedAt = Date.now()
1228
+ if (
1229
+ !options.force &&
1230
+ startedAt - this.lastPermissionPruneStartedAt <
1231
+ PERMISSION_PRUNE_INTERVAL_MS
1232
+ ) {
1233
+ return
1234
+ }
1235
+
1236
+ this.lastPermissionPruneStartedAt = startedAt
1237
+ const promise = this.pruneExpiredPermissionGrantsNow(now)
1238
+ this.permissionPrunePromise = promise
1239
+ try {
1240
+ await promise
1241
+ } catch (error) {
1242
+ this.lastPermissionPruneStartedAt = 0
1243
+ throw error
1244
+ } finally {
1245
+ if (this.permissionPrunePromise === promise) {
1246
+ this.permissionPrunePromise = null
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ private async pruneExpiredPermissionGrantsNow(now: Date): Promise<void> {
1252
+ await this.db.transaction(async (tx) => {
1253
+ const expiredEntityGrantIds = await tx
1254
+ .select({ id: entityPermissionGrants.id })
1255
+ .from(entityPermissionGrants)
1256
+ .where(
1257
+ and(
1258
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1259
+ sql`${entityPermissionGrants.expiresAt} IS NOT NULL`,
1260
+ lt(entityPermissionGrants.expiresAt, now)
1261
+ )
1262
+ )
1263
+ const ids = expiredEntityGrantIds.map((row) => row.id)
1264
+ if (ids.length > 0) {
1265
+ await tx
1266
+ .delete(entityEffectivePermissions)
1267
+ .where(
1268
+ and(
1269
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1270
+ inArray(entityEffectivePermissions.sourceGrantId, ids)
1271
+ )
1272
+ )
1273
+ await tx
1274
+ .delete(entityPermissionGrants)
1275
+ .where(
1276
+ and(
1277
+ eq(entityPermissionGrants.tenantId, this.tenantId),
1278
+ inArray(entityPermissionGrants.id, ids)
1279
+ )
1280
+ )
1281
+ }
1282
+
1283
+ await tx
1284
+ .delete(entityEffectivePermissions)
1285
+ .where(
1286
+ and(
1287
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1288
+ sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`,
1289
+ lt(entityEffectivePermissions.expiresAt, now)
1290
+ )
1291
+ )
1292
+ await tx
1293
+ .delete(entityTypePermissionGrants)
1294
+ .where(
1295
+ and(
1296
+ eq(entityTypePermissionGrants.tenantId, this.tenantId),
1297
+ sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`,
1298
+ lt(entityTypePermissionGrants.expiresAt, now)
1299
+ )
1300
+ )
1301
+ })
1302
+ }
1303
+
1304
+ private async materializeEntityPermissionGrant(
1305
+ tx: RegistryTransaction,
1306
+ grant: typeof entityPermissionGrants.$inferSelect
1307
+ ): Promise<void> {
1308
+ await tx
1309
+ .delete(entityEffectivePermissions)
1310
+ .where(
1311
+ and(
1312
+ eq(entityEffectivePermissions.tenantId, this.tenantId),
1313
+ eq(entityEffectivePermissions.sourceGrantId, grant.id)
1314
+ )
1315
+ )
1316
+
1317
+ if (grant.propagation === `descendants`) {
1318
+ await tx.execute(sql`
1319
+ INSERT INTO ${entityEffectivePermissions} (
1320
+ tenant_id,
1321
+ entity_url,
1322
+ source_entity_url,
1323
+ source_grant_id,
1324
+ permission,
1325
+ subject_kind,
1326
+ subject_value,
1327
+ expires_at
1328
+ )
1329
+ SELECT
1330
+ ${this.tenantId},
1331
+ descendant_url,
1332
+ ${grant.entityUrl},
1333
+ ${grant.id},
1334
+ ${grant.permission},
1335
+ ${grant.subjectKind},
1336
+ ${grant.subjectValue},
1337
+ ${grant.expiresAt}
1338
+ FROM ${entityLineage}
1339
+ WHERE tenant_id = ${this.tenantId}
1340
+ AND ancestor_url = ${grant.entityUrl}
1341
+ ON CONFLICT DO NOTHING
1342
+ `)
1343
+ return
1344
+ }
1345
+
1346
+ await tx
1347
+ .insert(entityEffectivePermissions)
1348
+ .values({
1349
+ tenantId: this.tenantId,
1350
+ entityUrl: grant.entityUrl,
1351
+ sourceEntityUrl: grant.entityUrl,
1352
+ sourceGrantId: grant.id,
1353
+ permission: grant.permission,
1354
+ subjectKind: grant.subjectKind,
1355
+ subjectValue: grant.subjectValue,
1356
+ expiresAt: grant.expiresAt,
1357
+ })
1358
+ .onConflictDoNothing()
1359
+ }
1360
+
793
1361
  async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
794
1362
  const whereClause = isTerminalEntityStatus(status)
795
1363
  ? this.entityWhere(entityUrl)
@@ -953,6 +1521,8 @@ export class PostgresRegistry {
953
1521
  sourceRef: string
954
1522
  tags: EntityTags
955
1523
  streamUrl: string
1524
+ principalUrl: string
1525
+ principalKind: string
956
1526
  }): Promise<EntityBridgeRow> {
957
1527
  await this.db
958
1528
  .insert(entityBridges)
@@ -961,6 +1531,8 @@ export class PostgresRegistry {
961
1531
  sourceRef: row.sourceRef,
962
1532
  tags: normalizeTags(row.tags),
963
1533
  streamUrl: row.streamUrl,
1534
+ principalUrl: row.principalUrl,
1535
+ principalKind: row.principalKind,
964
1536
  })
965
1537
  .onConflictDoNothing()
966
1538
 
@@ -1261,6 +1833,9 @@ export class PostgresRegistry {
1261
1833
  state_schemas: row.stateSchemas as
1262
1834
  | Record<string, Record<string, unknown>>
1263
1835
  | undefined,
1836
+ slash_commands:
1837
+ (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
1838
+ undefined,
1264
1839
  serve_endpoint: row.serveEndpoint ?? undefined,
1265
1840
  default_dispatch_policy:
1266
1841
  (row.defaultDispatchPolicy as ElectricAgentsEntityType[`default_dispatch_policy`]) ??
@@ -1271,6 +1846,40 @@ export class PostgresRegistry {
1271
1846
  }
1272
1847
  }
1273
1848
 
1849
+ private rowToEntityTypePermissionGrant(
1850
+ row: typeof entityTypePermissionGrants.$inferSelect
1851
+ ): EntityTypePermissionGrant {
1852
+ return {
1853
+ id: row.id,
1854
+ entity_type: row.entityType,
1855
+ permission: row.permission as EntityTypePermission,
1856
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1857
+ subject_value: row.subjectValue,
1858
+ created_by: row.createdBy ?? undefined,
1859
+ expires_at: row.expiresAt?.toISOString(),
1860
+ created_at: row.createdAt.toISOString(),
1861
+ updated_at: row.updatedAt.toISOString(),
1862
+ }
1863
+ }
1864
+
1865
+ private rowToEntityPermissionGrant(
1866
+ row: typeof entityPermissionGrants.$inferSelect
1867
+ ): EntityPermissionGrant {
1868
+ return {
1869
+ id: row.id,
1870
+ entity_url: row.entityUrl,
1871
+ permission: row.permission as EntityPermission,
1872
+ subject_kind: row.subjectKind as PermissionSubjectKind,
1873
+ subject_value: row.subjectValue,
1874
+ propagation: row.propagation as EntityPermissionPropagation,
1875
+ copy_to_children: row.copyToChildren,
1876
+ created_by: row.createdBy ?? undefined,
1877
+ expires_at: row.expiresAt?.toISOString(),
1878
+ created_at: row.createdAt.toISOString(),
1879
+ updated_at: row.updatedAt.toISOString(),
1880
+ }
1881
+ }
1882
+
1274
1883
  private rowToEntity(row: typeof entities.$inferSelect): ElectricAgentsEntity {
1275
1884
  return {
1276
1885
  url: row.url,
@@ -1278,7 +1887,6 @@ export class PostgresRegistry {
1278
1887
  status: assertEntityStatus(row.status),
1279
1888
  streams: {
1280
1889
  main: `${row.url}/main`,
1281
- error: `${row.url}/error`,
1282
1890
  },
1283
1891
  subscription_id: row.subscriptionId,
1284
1892
  dispatch_policy:
@@ -1311,6 +1919,8 @@ export class PostgresRegistry {
1311
1919
  sourceRef: row.sourceRef,
1312
1920
  tags: (row.tags as EntityTags | null | undefined) ?? {},
1313
1921
  streamUrl: row.streamUrl,
1922
+ principalUrl: row.principalUrl ?? undefined,
1923
+ principalKind: row.principalKind ?? undefined,
1314
1924
  shapeHandle: row.shapeHandle ?? undefined,
1315
1925
  shapeOffset: row.shapeOffset ?? undefined,
1316
1926
  lastObserverActivityAt: row.lastObserverActivityAt,