@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.
- package/dist/entrypoint.js +1530 -256
- package/dist/index.cjs +1517 -232
- package/dist/index.d.cts +1359 -212
- package/dist/index.d.ts +1359 -212
- package/dist/index.js +1519 -234
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/0011_entity_permissions.sql +100 -0
- package/drizzle/0012_horton_user_manage_permission.sql +25 -0
- package/drizzle/0013_worker_user_manage_permission.sql +25 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/db/schema.ts +200 -0
- package/src/electric-agents-types.ts +147 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +411 -62
- package/src/entity-projector.ts +79 -17
- package/src/entity-registry.ts +681 -5
- package/src/index.ts +11 -0
- package/src/permissions.ts +239 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +125 -4
- package/src/routing/electric-proxy-router.ts +4 -0
- package/src/routing/entities-router.ts +362 -20
- package/src/routing/entity-types-router.ts +244 -15
- package/src/routing/hooks.ts +1 -0
- package/src/routing/observations-router.ts +2 -1
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/runtime.ts +34 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/server-utils.ts +192 -12
- package/src/wake-registry.ts +30 -11
package/src/entity-registry.ts
CHANGED
|
@@ -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
|
}
|