@electric-ax/agents-server 0.4.15 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +1176 -232
- package/dist/index.cjs +1179 -226
- package/dist/index.d.cts +1146 -167
- package/dist/index.d.ts +1146 -167
- package/dist/index.js +1181 -228
- 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 +21 -0
- package/package.json +7 -7
- package/src/db/schema.ts +198 -0
- package/src/electric-agents-types.ts +76 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +78 -60
- package/src/entity-projector.ts +76 -17
- package/src/entity-registry.ts +608 -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 +344 -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/runtime.ts +34 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/utils/server-utils.ts +191 -11
- package/src/wake-registry.ts +8 -0
package/dist/index.js
CHANGED
|
@@ -5,15 +5,15 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
6
6
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
7
7
|
import postgres from "postgres";
|
|
8
|
-
import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
|
|
8
|
+
import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
9
9
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
10
10
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
11
|
-
import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
11
|
+
import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
12
12
|
import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
|
|
13
13
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
14
14
|
import pino from "pino";
|
|
15
|
-
import fastq from "fastq";
|
|
16
15
|
import { Type } from "@sinclair/typebox";
|
|
16
|
+
import fastq from "fastq";
|
|
17
17
|
import Ajv from "ajv";
|
|
18
18
|
import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
|
|
19
19
|
import { AutoRouter, Router, json, status, withParams } from "itty-router";
|
|
@@ -26,11 +26,16 @@ __export(schema_exports, {
|
|
|
26
26
|
entities: () => entities,
|
|
27
27
|
entityBridges: () => entityBridges,
|
|
28
28
|
entityDispatchState: () => entityDispatchState,
|
|
29
|
+
entityEffectivePermissions: () => entityEffectivePermissions,
|
|
30
|
+
entityLineage: () => entityLineage,
|
|
29
31
|
entityManifestSources: () => entityManifestSources,
|
|
32
|
+
entityPermissionGrants: () => entityPermissionGrants,
|
|
33
|
+
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
30
34
|
entityTypes: () => entityTypes,
|
|
31
35
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
32
36
|
runners: () => runners,
|
|
33
37
|
scheduledTasks: () => scheduledTasks,
|
|
38
|
+
sharedStateLinks: () => sharedStateLinks,
|
|
34
39
|
subscriptionWebhooks: () => subscriptionWebhooks,
|
|
35
40
|
tagStreamOutbox: () => tagStreamOutbox,
|
|
36
41
|
users: () => users,
|
|
@@ -78,6 +83,94 @@ const entities = pgTable(`entities`, {
|
|
|
78
83
|
index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
79
84
|
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
|
|
80
85
|
]);
|
|
86
|
+
const entityTypePermissionGrants = pgTable(`entity_type_permission_grants`, {
|
|
87
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
88
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
89
|
+
entityType: text(`entity_type`).notNull(),
|
|
90
|
+
permission: text(`permission`).notNull(),
|
|
91
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
92
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
93
|
+
createdBy: text(`created_by`),
|
|
94
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
95
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
96
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
97
|
+
}, (table) => [
|
|
98
|
+
index(`idx_type_permission_grants_lookup`).on(table.tenantId, table.entityType, table.permission, table.subjectKind, table.subjectValue),
|
|
99
|
+
index(`idx_type_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
|
|
100
|
+
check(`chk_type_permission_grants_permission`, sql`${table.permission} IN ('spawn', 'manage')`),
|
|
101
|
+
check(`chk_type_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
|
|
102
|
+
]);
|
|
103
|
+
const entityLineage = pgTable(`entity_lineage`, {
|
|
104
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
105
|
+
ancestorUrl: text(`ancestor_url`).notNull(),
|
|
106
|
+
descendantUrl: text(`descendant_url`).notNull(),
|
|
107
|
+
depth: integer(`depth`).notNull(),
|
|
108
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
|
|
109
|
+
}, (table) => [
|
|
110
|
+
primaryKey({ columns: [
|
|
111
|
+
table.tenantId,
|
|
112
|
+
table.ancestorUrl,
|
|
113
|
+
table.descendantUrl
|
|
114
|
+
] }),
|
|
115
|
+
index(`idx_entity_lineage_descendant`).on(table.tenantId, table.descendantUrl),
|
|
116
|
+
check(`chk_entity_lineage_depth`, sql`${table.depth} >= 0`)
|
|
117
|
+
]);
|
|
118
|
+
const entityPermissionGrants = pgTable(`entity_permission_grants`, {
|
|
119
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
120
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
121
|
+
entityUrl: text(`entity_url`).notNull(),
|
|
122
|
+
permission: text(`permission`).notNull(),
|
|
123
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
124
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
125
|
+
propagation: text(`propagation`).notNull().default(`self`),
|
|
126
|
+
copyToChildren: boolean(`copy_to_children`).notNull().default(false),
|
|
127
|
+
createdBy: text(`created_by`),
|
|
128
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
129
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
130
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
131
|
+
}, (table) => [
|
|
132
|
+
index(`idx_entity_permission_grants_entity`).on(table.tenantId, table.entityUrl),
|
|
133
|
+
index(`idx_entity_permission_grants_subject`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue),
|
|
134
|
+
index(`idx_entity_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
|
|
135
|
+
check(`chk_entity_permission_grants_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
|
|
136
|
+
check(`chk_entity_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`),
|
|
137
|
+
check(`chk_entity_permission_grants_propagation`, sql`${table.propagation} IN ('self', 'descendants')`)
|
|
138
|
+
]);
|
|
139
|
+
const entityEffectivePermissions = pgTable(`entity_effective_permissions`, {
|
|
140
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
141
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
142
|
+
entityUrl: text(`entity_url`).notNull(),
|
|
143
|
+
sourceEntityUrl: text(`source_entity_url`).notNull(),
|
|
144
|
+
sourceGrantId: bigint(`source_grant_id`, { mode: `number` }).notNull(),
|
|
145
|
+
permission: text(`permission`).notNull(),
|
|
146
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
147
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
148
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
149
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
|
|
150
|
+
}, (table) => [
|
|
151
|
+
unique(`uq_entity_effective_permission`).on(table.tenantId, table.entityUrl, table.sourceGrantId),
|
|
152
|
+
index(`idx_entity_effective_permissions_lookup`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue, table.entityUrl),
|
|
153
|
+
index(`idx_entity_effective_permissions_entity`).on(table.tenantId, table.entityUrl),
|
|
154
|
+
index(`idx_entity_effective_permissions_expiry`).on(table.tenantId, table.expiresAt),
|
|
155
|
+
check(`chk_entity_effective_permissions_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
|
|
156
|
+
check(`chk_entity_effective_permissions_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
|
|
157
|
+
]);
|
|
158
|
+
const sharedStateLinks = pgTable(`shared_state_links`, {
|
|
159
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
160
|
+
sharedStateId: text(`shared_state_id`).notNull(),
|
|
161
|
+
ownerEntityUrl: text(`owner_entity_url`).notNull(),
|
|
162
|
+
manifestKey: text(`manifest_key`).notNull(),
|
|
163
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
164
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
165
|
+
}, (table) => [
|
|
166
|
+
primaryKey({ columns: [
|
|
167
|
+
table.tenantId,
|
|
168
|
+
table.ownerEntityUrl,
|
|
169
|
+
table.manifestKey
|
|
170
|
+
] }),
|
|
171
|
+
index(`idx_shared_state_links_shared_state`).on(table.tenantId, table.sharedStateId),
|
|
172
|
+
index(`idx_shared_state_links_owner`).on(table.tenantId, table.ownerEntityUrl)
|
|
173
|
+
]);
|
|
81
174
|
const users = pgTable(`users`, {
|
|
82
175
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
83
176
|
id: text(`id`).notNull(),
|
|
@@ -264,12 +357,18 @@ const entityBridges = pgTable(`entity_bridges`, {
|
|
|
264
357
|
sourceRef: text(`source_ref`).notNull(),
|
|
265
358
|
tags: jsonb(`tags`).notNull(),
|
|
266
359
|
streamUrl: text(`stream_url`).notNull(),
|
|
360
|
+
principalUrl: text(`principal_url`),
|
|
361
|
+
principalKind: text(`principal_kind`),
|
|
267
362
|
shapeHandle: text(`shape_handle`),
|
|
268
363
|
shapeOffset: text(`shape_offset`),
|
|
269
364
|
lastObserverActivityAt: timestamp(`last_observer_activity_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
270
365
|
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
271
366
|
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
272
|
-
}, (table) => [
|
|
367
|
+
}, (table) => [
|
|
368
|
+
primaryKey({ columns: [table.tenantId, table.sourceRef] }),
|
|
369
|
+
unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl),
|
|
370
|
+
index(`idx_entity_bridges_principal`).on(table.tenantId, table.principalKind, table.principalUrl)
|
|
371
|
+
]);
|
|
273
372
|
const entityManifestSources = pgTable(`entity_manifest_sources`, {
|
|
274
373
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
275
374
|
ownerEntityUrl: text(`owner_entity_url`).notNull(),
|
|
@@ -457,16 +556,26 @@ function isDuplicateUrlError(err) {
|
|
|
457
556
|
return e.code === `23505`;
|
|
458
557
|
}
|
|
459
558
|
const DEFAULT_RUNNER_LEASE_MS = 3e4;
|
|
559
|
+
const PERMISSION_PRUNE_INTERVAL_MS = 3e4;
|
|
460
560
|
function runnerWakeStream(runnerId) {
|
|
461
561
|
return `/runners/${runnerId}/wake`;
|
|
462
562
|
}
|
|
463
563
|
var PostgresRegistry = class {
|
|
564
|
+
lastPermissionPruneStartedAt = 0;
|
|
565
|
+
permissionPrunePromise = null;
|
|
464
566
|
constructor(db, tenantId = DEFAULT_TENANT_ID) {
|
|
465
567
|
this.db = db;
|
|
466
568
|
this.tenantId = tenantId;
|
|
467
569
|
}
|
|
468
570
|
async initialize() {}
|
|
469
571
|
close() {}
|
|
572
|
+
async ensureUserForPrincipal(principal) {
|
|
573
|
+
if (principal.kind !== `user`) return;
|
|
574
|
+
await this.db.insert(users).values({
|
|
575
|
+
tenantId: this.tenantId,
|
|
576
|
+
id: principal.id
|
|
577
|
+
}).onConflictDoNothing();
|
|
578
|
+
}
|
|
470
579
|
async createRunner(input) {
|
|
471
580
|
const now = new Date();
|
|
472
581
|
const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
|
|
@@ -801,6 +910,59 @@ var PostgresRegistry = class {
|
|
|
801
910
|
pendingSourceStreams: [],
|
|
802
911
|
updatedAt: new Date()
|
|
803
912
|
}).onConflictDoNothing();
|
|
913
|
+
await tx.insert(entityLineage).values({
|
|
914
|
+
tenantId: this.tenantId,
|
|
915
|
+
ancestorUrl: entity.url,
|
|
916
|
+
descendantUrl: entity.url,
|
|
917
|
+
depth: 0
|
|
918
|
+
}).onConflictDoNothing();
|
|
919
|
+
if (entity.parent) await tx.execute(sql`
|
|
920
|
+
INSERT INTO ${entityLineage} (
|
|
921
|
+
tenant_id,
|
|
922
|
+
ancestor_url,
|
|
923
|
+
descendant_url,
|
|
924
|
+
depth
|
|
925
|
+
)
|
|
926
|
+
SELECT
|
|
927
|
+
${this.tenantId},
|
|
928
|
+
ancestor_url,
|
|
929
|
+
${entity.url},
|
|
930
|
+
depth + 1
|
|
931
|
+
FROM ${entityLineage}
|
|
932
|
+
WHERE tenant_id = ${this.tenantId}
|
|
933
|
+
AND descendant_url = ${entity.parent}
|
|
934
|
+
ON CONFLICT DO NOTHING
|
|
935
|
+
`);
|
|
936
|
+
await tx.execute(sql`
|
|
937
|
+
INSERT INTO ${entityEffectivePermissions} (
|
|
938
|
+
tenant_id,
|
|
939
|
+
entity_url,
|
|
940
|
+
source_entity_url,
|
|
941
|
+
source_grant_id,
|
|
942
|
+
permission,
|
|
943
|
+
subject_kind,
|
|
944
|
+
subject_value,
|
|
945
|
+
expires_at
|
|
946
|
+
)
|
|
947
|
+
SELECT
|
|
948
|
+
${this.tenantId},
|
|
949
|
+
${entity.url},
|
|
950
|
+
grants.entity_url,
|
|
951
|
+
grants.id,
|
|
952
|
+
grants.permission,
|
|
953
|
+
grants.subject_kind,
|
|
954
|
+
grants.subject_value,
|
|
955
|
+
grants.expires_at
|
|
956
|
+
FROM ${entityPermissionGrants} grants
|
|
957
|
+
JOIN ${entityLineage} lineage
|
|
958
|
+
ON lineage.tenant_id = grants.tenant_id
|
|
959
|
+
AND lineage.ancestor_url = grants.entity_url
|
|
960
|
+
AND lineage.descendant_url = ${entity.url}
|
|
961
|
+
WHERE grants.tenant_id = ${this.tenantId}
|
|
962
|
+
AND grants.propagation = 'descendants'
|
|
963
|
+
AND (grants.expires_at IS NULL OR grants.expires_at > now())
|
|
964
|
+
ON CONFLICT DO NOTHING
|
|
965
|
+
`);
|
|
804
966
|
return parseInt(result[0].txid);
|
|
805
967
|
});
|
|
806
968
|
} catch (err) {
|
|
@@ -822,10 +984,8 @@ var PostgresRegistry = class {
|
|
|
822
984
|
}
|
|
823
985
|
async getEntityByStream(streamPath) {
|
|
824
986
|
const mainSuffix = `/main`;
|
|
825
|
-
const errorSuffix = `/error`;
|
|
826
987
|
let entityUrl = null;
|
|
827
988
|
if (streamPath.endsWith(mainSuffix)) entityUrl = streamPath.slice(0, -mainSuffix.length);
|
|
828
|
-
else if (streamPath.endsWith(errorSuffix)) entityUrl = streamPath.slice(0, -errorSuffix.length);
|
|
829
989
|
if (!entityUrl) return null;
|
|
830
990
|
return this.getEntity(entityUrl);
|
|
831
991
|
}
|
|
@@ -835,6 +995,23 @@ var PostgresRegistry = class {
|
|
|
835
995
|
if (filter?.status) conditions.push(eq(entities.status, filter.status));
|
|
836
996
|
if (filter?.parent) conditions.push(eq(entities.parent, filter.parent));
|
|
837
997
|
if (filter?.created_by) conditions.push(eq(entities.createdBy, filter.created_by));
|
|
998
|
+
if (filter?.readableBy && !filter.readableBy.bypass) conditions.push(sql`(
|
|
999
|
+
${entities.createdBy} = ${filter.readableBy.principalUrl}
|
|
1000
|
+
OR ${entities.url} IN (
|
|
1001
|
+
SELECT ${entityEffectivePermissions.entityUrl}
|
|
1002
|
+
FROM ${entityEffectivePermissions}
|
|
1003
|
+
WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
|
|
1004
|
+
AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
|
|
1005
|
+
AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
|
|
1006
|
+
AND (
|
|
1007
|
+
(${entityEffectivePermissions.subjectKind} = 'principal'
|
|
1008
|
+
AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
|
|
1009
|
+
OR
|
|
1010
|
+
(${entityEffectivePermissions.subjectKind} = 'principal_kind'
|
|
1011
|
+
AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
|
|
1012
|
+
)
|
|
1013
|
+
)
|
|
1014
|
+
)`);
|
|
838
1015
|
const whereClause = and(...conditions);
|
|
839
1016
|
const countResult = await this.db.select({ count: sql`count(*)` }).from(entities).where(whereClause);
|
|
840
1017
|
const total = Number(countResult[0].count);
|
|
@@ -847,6 +1024,189 @@ var PostgresRegistry = class {
|
|
|
847
1024
|
total
|
|
848
1025
|
};
|
|
849
1026
|
}
|
|
1027
|
+
async createEntityTypePermissionGrant(input) {
|
|
1028
|
+
const [row] = await this.db.insert(entityTypePermissionGrants).values({
|
|
1029
|
+
tenantId: this.tenantId,
|
|
1030
|
+
entityType: input.entityType,
|
|
1031
|
+
permission: input.permission,
|
|
1032
|
+
subjectKind: input.subjectKind,
|
|
1033
|
+
subjectValue: input.subjectValue,
|
|
1034
|
+
createdBy: input.createdBy ?? null,
|
|
1035
|
+
expiresAt: input.expiresAt ?? null
|
|
1036
|
+
}).returning();
|
|
1037
|
+
return this.rowToEntityTypePermissionGrant(row);
|
|
1038
|
+
}
|
|
1039
|
+
async ensureEntityTypePermissionGrant(input) {
|
|
1040
|
+
const [existing] = await this.db.select().from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, input.entityType), eq(entityTypePermissionGrants.permission, input.permission), eq(entityTypePermissionGrants.subjectKind, input.subjectKind), eq(entityTypePermissionGrants.subjectValue, input.subjectValue), input.expiresAt ? eq(entityTypePermissionGrants.expiresAt, input.expiresAt) : sql`${entityTypePermissionGrants.expiresAt} IS NULL`)).limit(1);
|
|
1041
|
+
if (existing) return this.rowToEntityTypePermissionGrant(existing);
|
|
1042
|
+
return await this.createEntityTypePermissionGrant(input);
|
|
1043
|
+
}
|
|
1044
|
+
async listEntityTypePermissionGrants(entityType) {
|
|
1045
|
+
const rows = await this.db.select().from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType))).orderBy(entityTypePermissionGrants.id);
|
|
1046
|
+
return rows.map((row) => this.rowToEntityTypePermissionGrant(row));
|
|
1047
|
+
}
|
|
1048
|
+
async deleteEntityTypePermissionGrant(entityType, grantId) {
|
|
1049
|
+
const rows = await this.db.delete(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType), eq(entityTypePermissionGrants.id, grantId))).returning({ id: entityTypePermissionGrants.id });
|
|
1050
|
+
return rows.length > 0;
|
|
1051
|
+
}
|
|
1052
|
+
async hasEntityTypePermission(entityType, permission, subject) {
|
|
1053
|
+
const permissions = [permission, `manage`];
|
|
1054
|
+
const rows = await this.db.select({ id: entityTypePermissionGrants.id }).from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType), inArray(entityTypePermissionGrants.permission, [...permissions]), sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`, sql`(
|
|
1055
|
+
(${entityTypePermissionGrants.subjectKind} = 'principal'
|
|
1056
|
+
AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
|
|
1057
|
+
OR
|
|
1058
|
+
(${entityTypePermissionGrants.subjectKind} = 'principal_kind'
|
|
1059
|
+
AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
|
|
1060
|
+
)`)).limit(1);
|
|
1061
|
+
return rows.length > 0;
|
|
1062
|
+
}
|
|
1063
|
+
async createEntityPermissionGrant(input) {
|
|
1064
|
+
return await this.db.transaction(async (tx) => {
|
|
1065
|
+
const [row] = await tx.insert(entityPermissionGrants).values({
|
|
1066
|
+
tenantId: this.tenantId,
|
|
1067
|
+
entityUrl: input.entityUrl,
|
|
1068
|
+
permission: input.permission,
|
|
1069
|
+
subjectKind: input.subjectKind,
|
|
1070
|
+
subjectValue: input.subjectValue,
|
|
1071
|
+
propagation: input.propagation ?? `self`,
|
|
1072
|
+
copyToChildren: input.copyToChildren ?? false,
|
|
1073
|
+
createdBy: input.createdBy ?? null,
|
|
1074
|
+
expiresAt: input.expiresAt ?? null
|
|
1075
|
+
}).returning();
|
|
1076
|
+
await this.materializeEntityPermissionGrant(tx, row);
|
|
1077
|
+
return this.rowToEntityPermissionGrant(row);
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
async listEntityPermissionGrants(entityUrl) {
|
|
1081
|
+
const rows = await this.db.select().from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, entityUrl))).orderBy(entityPermissionGrants.id);
|
|
1082
|
+
return rows.map((row) => this.rowToEntityPermissionGrant(row));
|
|
1083
|
+
}
|
|
1084
|
+
async deleteEntityPermissionGrant(entityUrl, grantId) {
|
|
1085
|
+
return await this.db.transaction(async (tx) => {
|
|
1086
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grantId)));
|
|
1087
|
+
const rows = await tx.delete(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, entityUrl), eq(entityPermissionGrants.id, grantId))).returning({ id: entityPermissionGrants.id });
|
|
1088
|
+
return rows.length > 0;
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
async copyEntityPermissionGrantsForSpawn(parentEntityUrl, childEntityUrl, createdBy) {
|
|
1092
|
+
const parentGrants = await this.db.select().from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, parentEntityUrl), eq(entityPermissionGrants.copyToChildren, true), sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`));
|
|
1093
|
+
const copied = [];
|
|
1094
|
+
for (const grant of parentGrants) copied.push(await this.createEntityPermissionGrant({
|
|
1095
|
+
entityUrl: childEntityUrl,
|
|
1096
|
+
permission: grant.permission,
|
|
1097
|
+
subjectKind: grant.subjectKind,
|
|
1098
|
+
subjectValue: grant.subjectValue,
|
|
1099
|
+
propagation: `self`,
|
|
1100
|
+
copyToChildren: grant.copyToChildren,
|
|
1101
|
+
createdBy,
|
|
1102
|
+
expiresAt: grant.expiresAt ?? void 0
|
|
1103
|
+
}));
|
|
1104
|
+
return copied;
|
|
1105
|
+
}
|
|
1106
|
+
async hasEntityPermission(entityUrl, permission, subject) {
|
|
1107
|
+
const permissions = [permission, `manage`];
|
|
1108
|
+
const rows = await this.db.select({ id: entityEffectivePermissions.id }).from(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.entityUrl, entityUrl), inArray(entityEffectivePermissions.permission, [...permissions]), sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`, sql`(
|
|
1109
|
+
(${entityEffectivePermissions.subjectKind} = 'principal'
|
|
1110
|
+
AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
|
|
1111
|
+
OR
|
|
1112
|
+
(${entityEffectivePermissions.subjectKind} = 'principal_kind'
|
|
1113
|
+
AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
|
|
1114
|
+
)`)).limit(1);
|
|
1115
|
+
return rows.length > 0;
|
|
1116
|
+
}
|
|
1117
|
+
async replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId) {
|
|
1118
|
+
await this.db.delete(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl), eq(sharedStateLinks.manifestKey, manifestKey)));
|
|
1119
|
+
if (!sharedStateId) return;
|
|
1120
|
+
await this.db.insert(sharedStateLinks).values({
|
|
1121
|
+
tenantId: this.tenantId,
|
|
1122
|
+
ownerEntityUrl,
|
|
1123
|
+
manifestKey,
|
|
1124
|
+
sharedStateId
|
|
1125
|
+
}).onConflictDoUpdate({
|
|
1126
|
+
target: [
|
|
1127
|
+
sharedStateLinks.tenantId,
|
|
1128
|
+
sharedStateLinks.ownerEntityUrl,
|
|
1129
|
+
sharedStateLinks.manifestKey
|
|
1130
|
+
],
|
|
1131
|
+
set: {
|
|
1132
|
+
sharedStateId,
|
|
1133
|
+
updatedAt: new Date()
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
async listSharedStateLinkedEntityUrls(sharedStateId) {
|
|
1138
|
+
const rows = await this.db.selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl }).from(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.sharedStateId, sharedStateId)));
|
|
1139
|
+
return rows.map((row) => row.ownerEntityUrl);
|
|
1140
|
+
}
|
|
1141
|
+
async pruneExpiredPermissionGrants(now = new Date(), options = {}) {
|
|
1142
|
+
if (this.permissionPrunePromise) return await this.permissionPrunePromise;
|
|
1143
|
+
const startedAt = Date.now();
|
|
1144
|
+
if (!options.force && startedAt - this.lastPermissionPruneStartedAt < PERMISSION_PRUNE_INTERVAL_MS) return;
|
|
1145
|
+
this.lastPermissionPruneStartedAt = startedAt;
|
|
1146
|
+
const promise = this.pruneExpiredPermissionGrantsNow(now);
|
|
1147
|
+
this.permissionPrunePromise = promise;
|
|
1148
|
+
try {
|
|
1149
|
+
await promise;
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
this.lastPermissionPruneStartedAt = 0;
|
|
1152
|
+
throw error;
|
|
1153
|
+
} finally {
|
|
1154
|
+
if (this.permissionPrunePromise === promise) this.permissionPrunePromise = null;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async pruneExpiredPermissionGrantsNow(now) {
|
|
1158
|
+
await this.db.transaction(async (tx) => {
|
|
1159
|
+
const expiredEntityGrantIds = await tx.select({ id: entityPermissionGrants.id }).from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), sql`${entityPermissionGrants.expiresAt} IS NOT NULL`, lt(entityPermissionGrants.expiresAt, now)));
|
|
1160
|
+
const ids = expiredEntityGrantIds.map((row) => row.id);
|
|
1161
|
+
if (ids.length > 0) {
|
|
1162
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), inArray(entityEffectivePermissions.sourceGrantId, ids)));
|
|
1163
|
+
await tx.delete(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), inArray(entityPermissionGrants.id, ids)));
|
|
1164
|
+
}
|
|
1165
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`, lt(entityEffectivePermissions.expiresAt, now)));
|
|
1166
|
+
await tx.delete(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`, lt(entityTypePermissionGrants.expiresAt, now)));
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
async materializeEntityPermissionGrant(tx, grant) {
|
|
1170
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grant.id)));
|
|
1171
|
+
if (grant.propagation === `descendants`) {
|
|
1172
|
+
await tx.execute(sql`
|
|
1173
|
+
INSERT INTO ${entityEffectivePermissions} (
|
|
1174
|
+
tenant_id,
|
|
1175
|
+
entity_url,
|
|
1176
|
+
source_entity_url,
|
|
1177
|
+
source_grant_id,
|
|
1178
|
+
permission,
|
|
1179
|
+
subject_kind,
|
|
1180
|
+
subject_value,
|
|
1181
|
+
expires_at
|
|
1182
|
+
)
|
|
1183
|
+
SELECT
|
|
1184
|
+
${this.tenantId},
|
|
1185
|
+
descendant_url,
|
|
1186
|
+
${grant.entityUrl},
|
|
1187
|
+
${grant.id},
|
|
1188
|
+
${grant.permission},
|
|
1189
|
+
${grant.subjectKind},
|
|
1190
|
+
${grant.subjectValue},
|
|
1191
|
+
${grant.expiresAt}
|
|
1192
|
+
FROM ${entityLineage}
|
|
1193
|
+
WHERE tenant_id = ${this.tenantId}
|
|
1194
|
+
AND ancestor_url = ${grant.entityUrl}
|
|
1195
|
+
ON CONFLICT DO NOTHING
|
|
1196
|
+
`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
await tx.insert(entityEffectivePermissions).values({
|
|
1200
|
+
tenantId: this.tenantId,
|
|
1201
|
+
entityUrl: grant.entityUrl,
|
|
1202
|
+
sourceEntityUrl: grant.entityUrl,
|
|
1203
|
+
sourceGrantId: grant.id,
|
|
1204
|
+
permission: grant.permission,
|
|
1205
|
+
subjectKind: grant.subjectKind,
|
|
1206
|
+
subjectValue: grant.subjectValue,
|
|
1207
|
+
expiresAt: grant.expiresAt
|
|
1208
|
+
}).onConflictDoNothing();
|
|
1209
|
+
}
|
|
850
1210
|
async updateStatus(entityUrl, status$1) {
|
|
851
1211
|
const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
|
|
852
1212
|
await this.db.update(entities).set({
|
|
@@ -948,7 +1308,9 @@ var PostgresRegistry = class {
|
|
|
948
1308
|
tenantId: this.tenantId,
|
|
949
1309
|
sourceRef: row.sourceRef,
|
|
950
1310
|
tags: normalizeTags(row.tags),
|
|
951
|
-
streamUrl: row.streamUrl
|
|
1311
|
+
streamUrl: row.streamUrl,
|
|
1312
|
+
principalUrl: row.principalUrl,
|
|
1313
|
+
principalKind: row.principalKind
|
|
952
1314
|
}).onConflictDoNothing();
|
|
953
1315
|
const existing = await this.getEntityBridge(row.sourceRef);
|
|
954
1316
|
if (!existing) throw new Error(`Failed to load entity bridge ${row.sourceRef}`);
|
|
@@ -1110,15 +1472,40 @@ var PostgresRegistry = class {
|
|
|
1110
1472
|
updated_at: row.updatedAt
|
|
1111
1473
|
};
|
|
1112
1474
|
}
|
|
1475
|
+
rowToEntityTypePermissionGrant(row) {
|
|
1476
|
+
return {
|
|
1477
|
+
id: row.id,
|
|
1478
|
+
entity_type: row.entityType,
|
|
1479
|
+
permission: row.permission,
|
|
1480
|
+
subject_kind: row.subjectKind,
|
|
1481
|
+
subject_value: row.subjectValue,
|
|
1482
|
+
created_by: row.createdBy ?? void 0,
|
|
1483
|
+
expires_at: row.expiresAt?.toISOString(),
|
|
1484
|
+
created_at: row.createdAt.toISOString(),
|
|
1485
|
+
updated_at: row.updatedAt.toISOString()
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
rowToEntityPermissionGrant(row) {
|
|
1489
|
+
return {
|
|
1490
|
+
id: row.id,
|
|
1491
|
+
entity_url: row.entityUrl,
|
|
1492
|
+
permission: row.permission,
|
|
1493
|
+
subject_kind: row.subjectKind,
|
|
1494
|
+
subject_value: row.subjectValue,
|
|
1495
|
+
propagation: row.propagation,
|
|
1496
|
+
copy_to_children: row.copyToChildren,
|
|
1497
|
+
created_by: row.createdBy ?? void 0,
|
|
1498
|
+
expires_at: row.expiresAt?.toISOString(),
|
|
1499
|
+
created_at: row.createdAt.toISOString(),
|
|
1500
|
+
updated_at: row.updatedAt.toISOString()
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1113
1503
|
rowToEntity(row) {
|
|
1114
1504
|
return {
|
|
1115
1505
|
url: row.url,
|
|
1116
1506
|
type: row.type,
|
|
1117
1507
|
status: assertEntityStatus(row.status),
|
|
1118
|
-
streams: {
|
|
1119
|
-
main: `${row.url}/main`,
|
|
1120
|
-
error: `${row.url}/error`
|
|
1121
|
-
},
|
|
1508
|
+
streams: { main: `${row.url}/main` },
|
|
1122
1509
|
subscription_id: row.subscriptionId,
|
|
1123
1510
|
dispatch_policy: row.dispatchPolicy ?? void 0,
|
|
1124
1511
|
write_token: row.writeToken,
|
|
@@ -1140,6 +1527,8 @@ var PostgresRegistry = class {
|
|
|
1140
1527
|
sourceRef: row.sourceRef,
|
|
1141
1528
|
tags: row.tags ?? {},
|
|
1142
1529
|
streamUrl: row.streamUrl,
|
|
1530
|
+
principalUrl: row.principalUrl ?? void 0,
|
|
1531
|
+
principalKind: row.principalKind ?? void 0,
|
|
1143
1532
|
shapeHandle: row.shapeHandle ?? void 0,
|
|
1144
1533
|
shapeOffset: row.shapeOffset ?? void 0,
|
|
1145
1534
|
lastObserverActivityAt: row.lastObserverActivityAt,
|
|
@@ -1294,6 +1683,93 @@ const serverLog = {
|
|
|
1294
1683
|
}
|
|
1295
1684
|
};
|
|
1296
1685
|
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/principal.ts
|
|
1688
|
+
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
1689
|
+
const PRINCIPAL_KINDS = new Set([
|
|
1690
|
+
`user`,
|
|
1691
|
+
`agent`,
|
|
1692
|
+
`service`,
|
|
1693
|
+
`system`
|
|
1694
|
+
]);
|
|
1695
|
+
function parsePrincipalKey(input) {
|
|
1696
|
+
const colon = input.indexOf(`:`);
|
|
1697
|
+
if (colon <= 0) throw new Error(`Invalid principal identifier`);
|
|
1698
|
+
const kind = input.slice(0, colon);
|
|
1699
|
+
const id = input.slice(colon + 1);
|
|
1700
|
+
if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
|
|
1701
|
+
if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
|
|
1702
|
+
const key = `${kind}:${id}`;
|
|
1703
|
+
return {
|
|
1704
|
+
kind,
|
|
1705
|
+
id,
|
|
1706
|
+
key,
|
|
1707
|
+
url: `/principal/${encodeURIComponent(key)}`
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
function principalUrl(key) {
|
|
1711
|
+
return parsePrincipalKey(key).url;
|
|
1712
|
+
}
|
|
1713
|
+
function parsePrincipalUrl(url) {
|
|
1714
|
+
if (!url.startsWith(`/principal/`)) return null;
|
|
1715
|
+
const segment = url.slice(`/principal/`.length);
|
|
1716
|
+
if (!segment || segment.includes(`/`)) return null;
|
|
1717
|
+
try {
|
|
1718
|
+
return parsePrincipalKey(decodeURIComponent(segment));
|
|
1719
|
+
} catch {
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
|
|
1724
|
+
`framework`,
|
|
1725
|
+
`auth-sync`,
|
|
1726
|
+
`dev-local`
|
|
1727
|
+
]);
|
|
1728
|
+
function isBuiltInSystemPrincipalUrl(url) {
|
|
1729
|
+
if (!url?.startsWith(`/principal/`)) return false;
|
|
1730
|
+
try {
|
|
1731
|
+
const principal = parsePrincipalUrl(url);
|
|
1732
|
+
if (!principal) return false;
|
|
1733
|
+
return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
|
|
1734
|
+
} catch {
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
function principalFromCreatedBy(createdBy) {
|
|
1739
|
+
if (!createdBy) return void 0;
|
|
1740
|
+
const principal = parsePrincipalUrl(createdBy);
|
|
1741
|
+
if (!principal) return {
|
|
1742
|
+
url: createdBy,
|
|
1743
|
+
key: null
|
|
1744
|
+
};
|
|
1745
|
+
return {
|
|
1746
|
+
url: principal.url,
|
|
1747
|
+
key: principal.key,
|
|
1748
|
+
kind: principal.kind,
|
|
1749
|
+
id: principal.id
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
const principalIdentityStateSchema = Type.Object({
|
|
1753
|
+
kind: Type.Union([
|
|
1754
|
+
Type.Literal(`user`),
|
|
1755
|
+
Type.Literal(`agent`),
|
|
1756
|
+
Type.Literal(`service`),
|
|
1757
|
+
Type.Literal(`system`)
|
|
1758
|
+
]),
|
|
1759
|
+
id: Type.String(),
|
|
1760
|
+
key: Type.String(),
|
|
1761
|
+
url: Type.String(),
|
|
1762
|
+
updated_at: Type.String(),
|
|
1763
|
+
display_name: Type.Optional(Type.String()),
|
|
1764
|
+
email: Type.Optional(Type.String()),
|
|
1765
|
+
avatar_url: Type.Optional(Type.String()),
|
|
1766
|
+
auth_provider: Type.Optional(Type.String()),
|
|
1767
|
+
auth_subject: Type.Optional(Type.String()),
|
|
1768
|
+
claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
1769
|
+
created_at: Type.Optional(Type.String())
|
|
1770
|
+
}, { additionalProperties: false });
|
|
1771
|
+
const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
|
|
1772
|
+
|
|
1297
1773
|
//#endregion
|
|
1298
1774
|
//#region src/entity-projector.ts
|
|
1299
1775
|
const ENTITY_SHAPE_COLUMNS = [
|
|
@@ -1302,6 +1778,7 @@ const ENTITY_SHAPE_COLUMNS = [
|
|
|
1302
1778
|
`type`,
|
|
1303
1779
|
`status`,
|
|
1304
1780
|
`tags`,
|
|
1781
|
+
`created_by`,
|
|
1305
1782
|
`spawn_args`,
|
|
1306
1783
|
`sandbox`,
|
|
1307
1784
|
`parent`,
|
|
@@ -1321,6 +1798,12 @@ function sourceRefFromStreamPath(streamPath) {
|
|
|
1321
1798
|
const match = streamPath.match(/^\/_entities\/([^/]+)$/);
|
|
1322
1799
|
return match?.[1] ?? null;
|
|
1323
1800
|
}
|
|
1801
|
+
function principalScopedSourceRef(tagSourceRef, principalUrl$1, principalKind) {
|
|
1802
|
+
return `${tagSourceRef}-${hashString(JSON.stringify({
|
|
1803
|
+
principalKind,
|
|
1804
|
+
principalUrl: principalUrl$1
|
|
1805
|
+
}))}`;
|
|
1806
|
+
}
|
|
1324
1807
|
function sameMember(left, right) {
|
|
1325
1808
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
1326
1809
|
}
|
|
@@ -1351,15 +1834,22 @@ var ProjectedEntityBridge = class {
|
|
|
1351
1834
|
sourceRef;
|
|
1352
1835
|
tags;
|
|
1353
1836
|
streamUrl;
|
|
1837
|
+
principalUrl;
|
|
1838
|
+
principalKind;
|
|
1839
|
+
permissionBypass;
|
|
1354
1840
|
currentMembers = new Map();
|
|
1355
1841
|
producer = null;
|
|
1356
1842
|
stopped = false;
|
|
1357
|
-
constructor(row, streamClient) {
|
|
1843
|
+
constructor(row, registry, streamClient) {
|
|
1844
|
+
this.registry = registry;
|
|
1358
1845
|
this.streamClient = streamClient;
|
|
1359
1846
|
this.tenantId = row.tenantId;
|
|
1360
1847
|
this.sourceRef = row.sourceRef;
|
|
1361
1848
|
this.tags = normalizeTags(row.tags);
|
|
1362
1849
|
this.streamUrl = row.streamUrl;
|
|
1850
|
+
this.principalUrl = row.principalUrl;
|
|
1851
|
+
this.principalKind = row.principalKind;
|
|
1852
|
+
this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl);
|
|
1363
1853
|
}
|
|
1364
1854
|
async start(initialEntities) {
|
|
1365
1855
|
await this.ensureStream();
|
|
@@ -1373,7 +1863,7 @@ var ProjectedEntityBridge = class {
|
|
|
1373
1863
|
}
|
|
1374
1864
|
});
|
|
1375
1865
|
await this.loadCurrentMembers();
|
|
1376
|
-
this.reconcile(initialEntities);
|
|
1866
|
+
await this.reconcile(initialEntities);
|
|
1377
1867
|
}
|
|
1378
1868
|
async stop() {
|
|
1379
1869
|
this.stopped = true;
|
|
@@ -1385,12 +1875,13 @@ var ProjectedEntityBridge = class {
|
|
|
1385
1875
|
this.producer = null;
|
|
1386
1876
|
}
|
|
1387
1877
|
}
|
|
1388
|
-
reconcile(entities$1) {
|
|
1878
|
+
async reconcile(entities$1) {
|
|
1389
1879
|
if (this.stopped) return;
|
|
1390
1880
|
const staleMembers = new Map(this.currentMembers);
|
|
1391
1881
|
for (const entity of entities$1) {
|
|
1392
1882
|
if (entity.tenant_id !== this.tenantId) continue;
|
|
1393
1883
|
if (!entityMatchesTags(entity, this.tags)) continue;
|
|
1884
|
+
if (!await this.canReadEntity(entity)) continue;
|
|
1394
1885
|
staleMembers.delete(entity.url);
|
|
1395
1886
|
this.upsertEntity(entity);
|
|
1396
1887
|
}
|
|
@@ -1399,10 +1890,10 @@ var ProjectedEntityBridge = class {
|
|
|
1399
1890
|
this.currentMembers.delete(url);
|
|
1400
1891
|
}
|
|
1401
1892
|
}
|
|
1402
|
-
applyEntity(entity) {
|
|
1893
|
+
async applyEntity(entity) {
|
|
1403
1894
|
if (this.stopped) return;
|
|
1404
1895
|
if (entity.tenant_id !== this.tenantId) return;
|
|
1405
|
-
if (!entityMatchesTags(entity, this.tags)) {
|
|
1896
|
+
if (!entityMatchesTags(entity, this.tags) || !await this.canReadEntity(entity)) {
|
|
1406
1897
|
const existing = this.currentMembers.get(entity.url);
|
|
1407
1898
|
if (!existing) return;
|
|
1408
1899
|
this.append(`delete`, existing);
|
|
@@ -1431,6 +1922,15 @@ var ProjectedEntityBridge = class {
|
|
|
1431
1922
|
this.currentMembers.set(entity.url, next);
|
|
1432
1923
|
}
|
|
1433
1924
|
}
|
|
1925
|
+
async canReadEntity(entity) {
|
|
1926
|
+
if (this.permissionBypass) return true;
|
|
1927
|
+
if (!this.principalUrl || !this.principalKind) return false;
|
|
1928
|
+
if (entity.created_by === this.principalUrl) return true;
|
|
1929
|
+
return await this.registry.hasEntityPermission(entity.url, `read`, {
|
|
1930
|
+
principalUrl: this.principalUrl,
|
|
1931
|
+
principalKind: this.principalKind
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1434
1934
|
async ensureStream() {
|
|
1435
1935
|
if (!await this.streamClient.exists(this.streamUrl)) await this.streamClient.create(this.streamUrl, { contentType: `application/json` });
|
|
1436
1936
|
}
|
|
@@ -1535,17 +2035,19 @@ var EntityProjector = class {
|
|
|
1535
2035
|
this.activeReaders.clear();
|
|
1536
2036
|
await Promise.all(projections.map((projection) => projection.stop()));
|
|
1537
2037
|
}
|
|
1538
|
-
async register(tenantId, registry, tagsInput) {
|
|
2038
|
+
async register(tenantId, registry, tagsInput, principalUrl$1, principalKind) {
|
|
1539
2039
|
if (!this.electricUrl) throw new Error(`[entity-projector] Electric URL is required for entities()`);
|
|
1540
2040
|
await this.start();
|
|
1541
2041
|
this.registries.set(tenantId, registry);
|
|
1542
2042
|
const tags = normalizeTags(assertTags(tagsInput));
|
|
1543
|
-
const sourceRef = sourceRefForTags(tags);
|
|
2043
|
+
const sourceRef = principalScopedSourceRef(sourceRefForTags(tags), principalUrl$1, principalKind);
|
|
1544
2044
|
const streamUrl = getEntitiesStreamPath(sourceRef);
|
|
1545
2045
|
const row = await registry.upsertEntityBridge({
|
|
1546
2046
|
sourceRef,
|
|
1547
2047
|
tags,
|
|
1548
|
-
streamUrl
|
|
2048
|
+
streamUrl,
|
|
2049
|
+
principalUrl: principalUrl$1,
|
|
2050
|
+
principalKind
|
|
1549
2051
|
});
|
|
1550
2052
|
await registry.touchEntityBridge(sourceRef);
|
|
1551
2053
|
await this.ensureProjection(row);
|
|
@@ -1574,7 +2076,11 @@ var EntityProjector = class {
|
|
|
1574
2076
|
await this.touchSourceRef(tenantId, registry, sourceRef, `read-close`);
|
|
1575
2077
|
};
|
|
1576
2078
|
}
|
|
1577
|
-
async onEntityChanged(
|
|
2079
|
+
async onEntityChanged(tenantId, entityUrl) {
|
|
2080
|
+
const entity = this.entities.get(entityKey(tenantId, entityUrl));
|
|
2081
|
+
if (!entity) return;
|
|
2082
|
+
for (const projection of this.projectionsForTenant(tenantId)) await projection.applyEntity(entity);
|
|
2083
|
+
}
|
|
1578
2084
|
async loadTenantBridges(tenantId, registry = this.registryForTenant(tenantId)) {
|
|
1579
2085
|
if (!this.started || !this.electricUrl) return;
|
|
1580
2086
|
await this.loadPersistedBridgesForTenant(tenantId, registry);
|
|
@@ -1635,16 +2141,16 @@ var EntityProjector = class {
|
|
|
1635
2141
|
}
|
|
1636
2142
|
if (message.headers.control === `up-to-date`) {
|
|
1637
2143
|
this.upToDate = true;
|
|
1638
|
-
this.reconcileAll();
|
|
2144
|
+
await this.reconcileAll();
|
|
1639
2145
|
this.readyResolve?.();
|
|
1640
2146
|
}
|
|
1641
2147
|
continue;
|
|
1642
2148
|
}
|
|
1643
2149
|
if (!isChangeMessage(message)) continue;
|
|
1644
|
-
this.applyChangeMessage(message);
|
|
2150
|
+
await this.applyChangeMessage(message);
|
|
1645
2151
|
}
|
|
1646
2152
|
}
|
|
1647
|
-
applyChangeMessage(message) {
|
|
2153
|
+
async applyChangeMessage(message) {
|
|
1648
2154
|
const entity = message.value;
|
|
1649
2155
|
const key = entityKey(entity.tenant_id, entity.url);
|
|
1650
2156
|
if (message.headers.operation === `delete`) {
|
|
@@ -1653,7 +2159,7 @@ var EntityProjector = class {
|
|
|
1653
2159
|
return;
|
|
1654
2160
|
}
|
|
1655
2161
|
this.entities.set(key, entity);
|
|
1656
|
-
if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) projection.applyEntity(entity);
|
|
2162
|
+
if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) await projection.applyEntity(entity);
|
|
1657
2163
|
}
|
|
1658
2164
|
async loadPersistedBridges() {
|
|
1659
2165
|
const registry = new PostgresRegistry(this.db);
|
|
@@ -1716,7 +2222,7 @@ var EntityProjector = class {
|
|
|
1716
2222
|
}
|
|
1717
2223
|
throw error;
|
|
1718
2224
|
}
|
|
1719
|
-
const projection = new ProjectedEntityBridge(row, streamClient);
|
|
2225
|
+
const projection = new ProjectedEntityBridge(row, this.registryForTenant(row.tenantId), streamClient);
|
|
1720
2226
|
await projection.start(this.entitiesForTenant(row.tenantId));
|
|
1721
2227
|
this.projections.set(key, projection);
|
|
1722
2228
|
})().finally(() => {
|
|
@@ -1731,8 +2237,8 @@ var EntityProjector = class {
|
|
|
1731
2237
|
projectionsForTenant(tenantId) {
|
|
1732
2238
|
return [...this.projections.values()].filter((projection) => projection.tenantId === tenantId);
|
|
1733
2239
|
}
|
|
1734
|
-
reconcileAll() {
|
|
1735
|
-
for (const projection of this.projections.values()) projection.reconcile(this.entitiesForTenant(projection.tenantId));
|
|
2240
|
+
async reconcileAll() {
|
|
2241
|
+
for (const projection of this.projections.values()) await projection.reconcile(this.entitiesForTenant(projection.tenantId));
|
|
1736
2242
|
}
|
|
1737
2243
|
async touchSourceRef(tenantId, registry, sourceRef, reason) {
|
|
1738
2244
|
try {
|
|
@@ -1774,8 +2280,8 @@ var EntityProjectorTenantFacade = class {
|
|
|
1774
2280
|
await this.projector.start();
|
|
1775
2281
|
}
|
|
1776
2282
|
async stop() {}
|
|
1777
|
-
async register(tagsInput) {
|
|
1778
|
-
return await this.projector.register(this.tenantId, this.registry, tagsInput);
|
|
2283
|
+
async register(tagsInput, principalUrl$1, principalKind) {
|
|
2284
|
+
return await this.projector.register(this.tenantId, this.registry, tagsInput, principalUrl$1, principalKind);
|
|
1779
2285
|
}
|
|
1780
2286
|
async onEntityChanged(entityUrl) {
|
|
1781
2287
|
await this.projector.onEntityChanged(this.tenantId, entityUrl);
|
|
@@ -2657,93 +3163,6 @@ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
|
2657
3163
|
if (!pinnedToSingleRunner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`, 400);
|
|
2658
3164
|
}
|
|
2659
3165
|
|
|
2660
|
-
//#endregion
|
|
2661
|
-
//#region src/principal.ts
|
|
2662
|
-
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
2663
|
-
const PRINCIPAL_KINDS = new Set([
|
|
2664
|
-
`user`,
|
|
2665
|
-
`agent`,
|
|
2666
|
-
`service`,
|
|
2667
|
-
`system`
|
|
2668
|
-
]);
|
|
2669
|
-
function parsePrincipalKey(input) {
|
|
2670
|
-
const colon = input.indexOf(`:`);
|
|
2671
|
-
if (colon <= 0) throw new Error(`Invalid principal identifier`);
|
|
2672
|
-
const kind = input.slice(0, colon);
|
|
2673
|
-
const id = input.slice(colon + 1);
|
|
2674
|
-
if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
|
|
2675
|
-
if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
|
|
2676
|
-
const key = `${kind}:${id}`;
|
|
2677
|
-
return {
|
|
2678
|
-
kind,
|
|
2679
|
-
id,
|
|
2680
|
-
key,
|
|
2681
|
-
url: `/principal/${encodeURIComponent(key)}`
|
|
2682
|
-
};
|
|
2683
|
-
}
|
|
2684
|
-
function principalUrl(key) {
|
|
2685
|
-
return parsePrincipalKey(key).url;
|
|
2686
|
-
}
|
|
2687
|
-
function parsePrincipalUrl(url) {
|
|
2688
|
-
if (!url.startsWith(`/principal/`)) return null;
|
|
2689
|
-
const segment = url.slice(`/principal/`.length);
|
|
2690
|
-
if (!segment || segment.includes(`/`)) return null;
|
|
2691
|
-
try {
|
|
2692
|
-
return parsePrincipalKey(decodeURIComponent(segment));
|
|
2693
|
-
} catch {
|
|
2694
|
-
return null;
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
|
|
2698
|
-
`framework`,
|
|
2699
|
-
`auth-sync`,
|
|
2700
|
-
`dev-local`
|
|
2701
|
-
]);
|
|
2702
|
-
function isBuiltInSystemPrincipalUrl(url) {
|
|
2703
|
-
if (!url?.startsWith(`/principal/`)) return false;
|
|
2704
|
-
try {
|
|
2705
|
-
const principal = parsePrincipalUrl(url);
|
|
2706
|
-
if (!principal) return false;
|
|
2707
|
-
return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
|
|
2708
|
-
} catch {
|
|
2709
|
-
return false;
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
function principalFromCreatedBy(createdBy) {
|
|
2713
|
-
if (!createdBy) return void 0;
|
|
2714
|
-
const principal = parsePrincipalUrl(createdBy);
|
|
2715
|
-
if (!principal) return {
|
|
2716
|
-
url: createdBy,
|
|
2717
|
-
key: null
|
|
2718
|
-
};
|
|
2719
|
-
return {
|
|
2720
|
-
url: principal.url,
|
|
2721
|
-
key: principal.key,
|
|
2722
|
-
kind: principal.kind,
|
|
2723
|
-
id: principal.id
|
|
2724
|
-
};
|
|
2725
|
-
}
|
|
2726
|
-
const principalIdentityStateSchema = Type.Object({
|
|
2727
|
-
kind: Type.Union([
|
|
2728
|
-
Type.Literal(`user`),
|
|
2729
|
-
Type.Literal(`agent`),
|
|
2730
|
-
Type.Literal(`service`),
|
|
2731
|
-
Type.Literal(`system`)
|
|
2732
|
-
]),
|
|
2733
|
-
id: Type.String(),
|
|
2734
|
-
key: Type.String(),
|
|
2735
|
-
url: Type.String(),
|
|
2736
|
-
updated_at: Type.String(),
|
|
2737
|
-
display_name: Type.Optional(Type.String()),
|
|
2738
|
-
email: Type.Optional(Type.String()),
|
|
2739
|
-
avatar_url: Type.Optional(Type.String()),
|
|
2740
|
-
auth_provider: Type.Optional(Type.String()),
|
|
2741
|
-
auth_subject: Type.Optional(Type.String()),
|
|
2742
|
-
claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
2743
|
-
created_at: Type.Optional(Type.String())
|
|
2744
|
-
}, { additionalProperties: false });
|
|
2745
|
-
const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
|
|
2746
|
-
|
|
2747
3166
|
//#endregion
|
|
2748
3167
|
//#region src/manifest-side-effects.ts
|
|
2749
3168
|
function isRecord$1(value) {
|
|
@@ -2979,7 +3398,10 @@ var EntityManager = class {
|
|
|
2979
3398
|
}
|
|
2980
3399
|
async ensurePrincipal(principal) {
|
|
2981
3400
|
const existing = await this.registry.getEntity(principal.url);
|
|
2982
|
-
if (existing)
|
|
3401
|
+
if (existing) {
|
|
3402
|
+
await this.ensureUserPrincipal(principal);
|
|
3403
|
+
return existing;
|
|
3404
|
+
}
|
|
2983
3405
|
await this.ensurePrincipalEntityType();
|
|
2984
3406
|
try {
|
|
2985
3407
|
const entity = await this.spawn(`principal`, {
|
|
@@ -3008,15 +3430,22 @@ var EntityManager = class {
|
|
|
3008
3430
|
updated_at: now
|
|
3009
3431
|
}
|
|
3010
3432
|
}));
|
|
3433
|
+
await this.ensureUserPrincipal(principal);
|
|
3011
3434
|
return entity;
|
|
3012
3435
|
} catch (error) {
|
|
3013
3436
|
if (error instanceof ElectricAgentsError && error.code === ErrCodeDuplicateURL) {
|
|
3014
3437
|
const raced = await this.registry.getEntity(principal.url);
|
|
3015
|
-
if (raced)
|
|
3438
|
+
if (raced) {
|
|
3439
|
+
await this.ensureUserPrincipal(principal);
|
|
3440
|
+
return raced;
|
|
3441
|
+
}
|
|
3016
3442
|
}
|
|
3017
3443
|
throw error;
|
|
3018
3444
|
}
|
|
3019
3445
|
}
|
|
3446
|
+
async ensureUserPrincipal(principal) {
|
|
3447
|
+
if (principal.kind === `user`) await this.registry.ensureUserForPrincipal(principal);
|
|
3448
|
+
}
|
|
3020
3449
|
/**
|
|
3021
3450
|
* Spawn a new entity of the given type with durable streams.
|
|
3022
3451
|
*/
|
|
@@ -3046,7 +3475,6 @@ var EntityManager = class {
|
|
|
3046
3475
|
const writeToken = randomUUID();
|
|
3047
3476
|
const entityURL = typeName === `principal` ? principalUrl(instanceId) : `/${typeName}/${instanceId}`;
|
|
3048
3477
|
const mainPath = `${entityURL}/main`;
|
|
3049
|
-
const errorPath = `${entityURL}/error`;
|
|
3050
3478
|
const subscriptionId = `${typeName}-handler`;
|
|
3051
3479
|
const spawnT0 = performance.now();
|
|
3052
3480
|
const existingByURL = await this.registry.getEntity(entityURL);
|
|
@@ -3063,10 +3491,7 @@ var EntityManager = class {
|
|
|
3063
3491
|
type: typeName,
|
|
3064
3492
|
status: `idle`,
|
|
3065
3493
|
url: entityURL,
|
|
3066
|
-
streams: {
|
|
3067
|
-
main: mainPath,
|
|
3068
|
-
error: errorPath
|
|
3069
|
-
},
|
|
3494
|
+
streams: { main: mainPath },
|
|
3070
3495
|
subscription_id: subscriptionId,
|
|
3071
3496
|
dispatch_policy: dispatchPolicy,
|
|
3072
3497
|
write_token: writeToken,
|
|
@@ -3119,55 +3544,43 @@ var EntityManager = class {
|
|
|
3119
3544
|
const queueEnterT0 = performance.now();
|
|
3120
3545
|
const queueWaiting = this.spawnPersistQueue.length();
|
|
3121
3546
|
const queueRunning = this.spawnPersistQueue.running();
|
|
3122
|
-
const [mainStreamResult,
|
|
3547
|
+
const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
|
|
3123
3548
|
let entityTxid;
|
|
3124
3549
|
try {
|
|
3125
3550
|
entityTxid = await withSpan(`db.createEntity`, () => this.registry.createEntity(entityData));
|
|
3126
3551
|
} catch (err) {
|
|
3127
|
-
return [
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
value: void 0
|
|
3135
|
-
},
|
|
3136
|
-
{
|
|
3137
|
-
status: `rejected`,
|
|
3138
|
-
reason: err
|
|
3139
|
-
}
|
|
3140
|
-
];
|
|
3552
|
+
return [{
|
|
3553
|
+
status: `fulfilled`,
|
|
3554
|
+
value: void 0
|
|
3555
|
+
}, {
|
|
3556
|
+
status: `rejected`,
|
|
3557
|
+
reason: err
|
|
3558
|
+
}];
|
|
3141
3559
|
}
|
|
3142
|
-
const [mainStreamResult$1
|
|
3560
|
+
const [mainStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
|
|
3143
3561
|
contentType,
|
|
3144
3562
|
body: initialBody
|
|
3145
|
-
})
|
|
3146
|
-
return [
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
status: `fulfilled`,
|
|
3151
|
-
value: entityTxid
|
|
3152
|
-
}
|
|
3153
|
-
];
|
|
3563
|
+
})]);
|
|
3564
|
+
return [mainStreamResult$1, {
|
|
3565
|
+
status: `fulfilled`,
|
|
3566
|
+
value: entityTxid
|
|
3567
|
+
}];
|
|
3154
3568
|
});
|
|
3155
3569
|
const parallelMs = +(performance.now() - queueEnterT0).toFixed(2);
|
|
3156
|
-
if (mainStreamResult.status === `rejected` ||
|
|
3570
|
+
if (mainStreamResult.status === `rejected` || entityResult.status === `rejected`) {
|
|
3157
3571
|
const entityReason = entityResult.status === `rejected` ? entityResult.reason : null;
|
|
3158
|
-
const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason :
|
|
3572
|
+
const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : null;
|
|
3159
3573
|
const isDuplicate = entityReason instanceof EntityAlreadyExistsError;
|
|
3160
3574
|
const isStreamConflict = !!streamReason && typeof streamReason === `object` && (`status` in streamReason && streamReason.status === 409 || `code` in streamReason && streamReason.code === `CONFLICT_SEQ`);
|
|
3161
3575
|
const rollbacks = [];
|
|
3162
3576
|
if (!isDuplicate && !isStreamConflict) {
|
|
3163
3577
|
if (mainStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(mainPath));
|
|
3164
|
-
if (errorStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(errorPath));
|
|
3165
3578
|
if (entityResult.status === `fulfilled`) rollbacks.push(this.registry.deleteEntity(entityURL));
|
|
3166
3579
|
if (req.wake) rollbacks.push(this.wakeRegistry.unregisterBySubscriberAndSource(req.wake.subscriberUrl, entityURL, this.tenantId));
|
|
3167
3580
|
await Promise.allSettled(rollbacks);
|
|
3168
3581
|
}
|
|
3169
3582
|
if (isDuplicate || isStreamConflict) throw new ElectricAgentsError(ErrCodeDuplicateURL, `Entity already exists at URL "${entityURL}"`, 409);
|
|
3170
|
-
const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason :
|
|
3583
|
+
const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : entityResult.reason;
|
|
3171
3584
|
if (failure instanceof Error) throw failure;
|
|
3172
3585
|
throw new ElectricAgentsError(`SPAWN_FAILED`, `Spawn failed: ${String(failure)}`, 500);
|
|
3173
3586
|
}
|
|
@@ -3252,7 +3665,7 @@ var EntityManager = class {
|
|
|
3252
3665
|
});
|
|
3253
3666
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3254
3667
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3255
|
-
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap);
|
|
3668
|
+
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
|
|
3256
3669
|
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3257
3670
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
3258
3671
|
const createdStreams = [];
|
|
@@ -3263,8 +3676,6 @@ var EntityManager = class {
|
|
|
3263
3676
|
const isRoot = plan.source.url === rootUrl;
|
|
3264
3677
|
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
|
|
3265
3678
|
createdStreams.push(plan.fork.streams.main);
|
|
3266
|
-
await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
|
|
3267
|
-
createdStreams.push(plan.fork.streams.error);
|
|
3268
3679
|
}
|
|
3269
3680
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
3270
3681
|
const sourcePath = getSharedStateStreamPath(sourceId);
|
|
@@ -3598,7 +4009,6 @@ var EntityManager = class {
|
|
|
3598
4009
|
for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
3599
4010
|
stringMap.set(sourceUrl, forkUrl);
|
|
3600
4011
|
stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`);
|
|
3601
|
-
stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`);
|
|
3602
4012
|
}
|
|
3603
4013
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
3604
4014
|
stringMap.set(sourceId, forkId);
|
|
@@ -3606,7 +4016,7 @@ var EntityManager = class {
|
|
|
3606
4016
|
}
|
|
3607
4017
|
return stringMap;
|
|
3608
4018
|
}
|
|
3609
|
-
buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap) {
|
|
4019
|
+
buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap, createdBy) {
|
|
3610
4020
|
const now = Date.now();
|
|
3611
4021
|
return entitiesToFork.map((source) => {
|
|
3612
4022
|
const forkUrl = entityUrlMap.get(source.url);
|
|
@@ -3619,14 +4029,12 @@ var EntityManager = class {
|
|
|
3619
4029
|
url: forkUrl,
|
|
3620
4030
|
type,
|
|
3621
4031
|
status: `idle`,
|
|
3622
|
-
streams: {
|
|
3623
|
-
main: `${forkUrl}/main`,
|
|
3624
|
-
error: `${forkUrl}/error`
|
|
3625
|
-
},
|
|
4032
|
+
streams: { main: `${forkUrl}/main` },
|
|
3626
4033
|
subscription_id: `${type}-handler`,
|
|
3627
4034
|
write_token: randomUUID(),
|
|
3628
4035
|
spawn_args: spawnArgs,
|
|
3629
4036
|
parent,
|
|
4037
|
+
created_by: createdBy ?? source.created_by,
|
|
3630
4038
|
created_at: now,
|
|
3631
4039
|
updated_at: now
|
|
3632
4040
|
};
|
|
@@ -3860,7 +4268,7 @@ var EntityManager = class {
|
|
|
3860
4268
|
}
|
|
3861
4269
|
async materializeForkManifestSideEffects(entityUrl, manifests) {
|
|
3862
4270
|
for (const [manifestKey, manifest] of manifests) {
|
|
3863
|
-
await this.
|
|
4271
|
+
await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest);
|
|
3864
4272
|
const wake = buildManifestWakeRegistration(entityUrl, manifest, manifestKey);
|
|
3865
4273
|
if (wake) await this.wakeRegistry.register({
|
|
3866
4274
|
...wake,
|
|
@@ -3890,6 +4298,7 @@ var EntityManager = class {
|
|
|
3890
4298
|
await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
|
|
3891
4299
|
entityUrl: targetUrl,
|
|
3892
4300
|
from: senderUrl,
|
|
4301
|
+
from_agent: senderUrl,
|
|
3893
4302
|
payload: manifest.payload,
|
|
3894
4303
|
key: `scheduled-${producerId}`,
|
|
3895
4304
|
type: typeof manifest.messageType === `string` ? manifest.messageType : void 0,
|
|
@@ -3929,12 +4338,14 @@ var EntityManager = class {
|
|
|
3929
4338
|
const now = new Date().toISOString();
|
|
3930
4339
|
const key = req.key ?? `msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3931
4340
|
const value = {
|
|
3932
|
-
from: req.from,
|
|
4341
|
+
from: req.from_principal ?? req.from,
|
|
3933
4342
|
payload: req.payload,
|
|
3934
4343
|
timestamp: now,
|
|
3935
4344
|
mode: req.mode ?? `immediate`,
|
|
3936
4345
|
status: req.mode === `queued` || req.mode === `paused` ? `pending` : `processed`
|
|
3937
4346
|
};
|
|
4347
|
+
if (req.from_principal) value.from_principal = req.from_principal;
|
|
4348
|
+
if (req.from_agent) value.from_agent = req.from_agent;
|
|
3938
4349
|
if (req.type) value.message_type = req.type;
|
|
3939
4350
|
if (req.position) value.position = req.position;
|
|
3940
4351
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
@@ -4106,9 +4517,9 @@ var EntityManager = class {
|
|
|
4106
4517
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4107
4518
|
return updated;
|
|
4108
4519
|
}
|
|
4109
|
-
async ensureEntitiesMembershipStream(tags) {
|
|
4520
|
+
async ensureEntitiesMembershipStream(tags, principal) {
|
|
4110
4521
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
4111
|
-
return this.entityBridgeManager.register(this.validateTags(tags));
|
|
4522
|
+
return this.entityBridgeManager.register(this.validateTags(tags), principal.url, principal.kind);
|
|
4112
4523
|
}
|
|
4113
4524
|
async writeManifestEntry(entityUrl, key, operation, value, opts) {
|
|
4114
4525
|
const entity = await this.registry.getEntity(entityUrl);
|
|
@@ -4126,11 +4537,11 @@ var EntityManager = class {
|
|
|
4126
4537
|
const encoded = this.encodeChangeEvent(event);
|
|
4127
4538
|
if (opts?.producerId) {
|
|
4128
4539
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
4129
|
-
await this.
|
|
4540
|
+
await this.syncManifestLinks(entityUrl, key, operation, value);
|
|
4130
4541
|
return;
|
|
4131
4542
|
}
|
|
4132
4543
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
4133
|
-
await this.
|
|
4544
|
+
await this.syncManifestLinks(entityUrl, key, operation, value);
|
|
4134
4545
|
}
|
|
4135
4546
|
async upsertCronSchedule(entityUrl, req) {
|
|
4136
4547
|
if (req.payload === void 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Missing required field: payload`, 400);
|
|
@@ -4279,6 +4690,8 @@ var EntityManager = class {
|
|
|
4279
4690
|
await this.scheduler.enqueueDelayedSend({
|
|
4280
4691
|
entityUrl,
|
|
4281
4692
|
from: req.from,
|
|
4693
|
+
from_principal: req.from_principal,
|
|
4694
|
+
from_agent: req.from_agent,
|
|
4282
4695
|
payload: req.payload,
|
|
4283
4696
|
key: req.key,
|
|
4284
4697
|
type: req.type,
|
|
@@ -4321,14 +4734,23 @@ var EntityManager = class {
|
|
|
4321
4734
|
await this.streamClient.appendIdempotent(subscriber.streams.main, this.encodeChangeEvent(wakeEvent), { producerId: `wake-reg-${result.registrationDbId}-${result.sourceEventKey}` });
|
|
4322
4735
|
});
|
|
4323
4736
|
}
|
|
4324
|
-
async
|
|
4737
|
+
async syncManifestLinks(entityUrl, manifestKey, operation, value) {
|
|
4325
4738
|
const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
|
|
4326
4739
|
await this.registry.replaceEntityManifestSource(entityUrl, manifestKey, sourceRef);
|
|
4740
|
+
const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
|
|
4741
|
+
await this.registry.replaceSharedStateLink(entityUrl, manifestKey, sharedStateId);
|
|
4327
4742
|
}
|
|
4328
4743
|
extractEntitiesSourceRef(manifest) {
|
|
4329
4744
|
if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
4330
4745
|
return void 0;
|
|
4331
4746
|
}
|
|
4747
|
+
extractSharedStateId(manifest) {
|
|
4748
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
|
|
4749
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
|
|
4750
|
+
if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
4751
|
+
const config = isRecord(manifest.config) ? manifest.config : void 0;
|
|
4752
|
+
return typeof config?.id === `string` ? config.id : void 0;
|
|
4753
|
+
}
|
|
4332
4754
|
/**
|
|
4333
4755
|
* Read a child entity's stream and extract concatenated text deltas
|
|
4334
4756
|
* for a specific run, plus any error messages for that run.
|
|
@@ -4492,14 +4914,7 @@ var EntityManager = class {
|
|
|
4492
4914
|
await this.streamClient.append(entity.streams.main, signalData);
|
|
4493
4915
|
return;
|
|
4494
4916
|
}
|
|
4495
|
-
const
|
|
4496
|
-
type: `signal`,
|
|
4497
|
-
key: signalEvent.key,
|
|
4498
|
-
value: signalEvent.value,
|
|
4499
|
-
headers: signalEvent.headers
|
|
4500
|
-
};
|
|
4501
|
-
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4502
|
-
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
4917
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData]]) try {
|
|
4503
4918
|
await this.streamClient.append(streamPath, data, { close: true });
|
|
4504
4919
|
} catch (err) {
|
|
4505
4920
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -5482,6 +5897,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5482
5897
|
try {
|
|
5483
5898
|
await this.manager.send(payload.entityUrl, {
|
|
5484
5899
|
from: payload.from,
|
|
5900
|
+
from_principal: payload.from_principal,
|
|
5901
|
+
from_agent: payload.from_agent,
|
|
5485
5902
|
payload: payload.payload,
|
|
5486
5903
|
key: payload.key ?? `scheduled-task-${taskId}`,
|
|
5487
5904
|
type: payload.type
|
|
@@ -5554,6 +5971,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5554
5971
|
await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
|
|
5555
5972
|
entityUrl: targetUrl,
|
|
5556
5973
|
from: senderUrl,
|
|
5974
|
+
from_agent: senderUrl,
|
|
5557
5975
|
payload: value.payload,
|
|
5558
5976
|
key: `scheduled-${producerId}`,
|
|
5559
5977
|
type: typeof value.messageType === `string` ? value.messageType : void 0,
|
|
@@ -5578,11 +5996,20 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5578
5996
|
async applyManifestEntitySource(ownerEntityUrl, manifestKey, operation, value) {
|
|
5579
5997
|
const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
|
|
5580
5998
|
await this.manager.registry.replaceEntityManifestSource(ownerEntityUrl, manifestKey, sourceRef);
|
|
5999
|
+
const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
|
|
6000
|
+
await this.manager.registry.replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId);
|
|
5581
6001
|
}
|
|
5582
6002
|
extractEntitiesSourceRef(manifest) {
|
|
5583
6003
|
if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
5584
6004
|
return void 0;
|
|
5585
6005
|
}
|
|
6006
|
+
extractSharedStateId(manifest) {
|
|
6007
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
|
|
6008
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
|
|
6009
|
+
if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
6010
|
+
const config = typeof manifest.config === `object` && manifest.config !== null && !Array.isArray(manifest.config) ? manifest.config : void 0;
|
|
6011
|
+
return typeof config?.id === `string` ? config.id : void 0;
|
|
6012
|
+
}
|
|
5586
6013
|
async maybeMarkEntityIdleAfterRunFinished(entityUrl) {
|
|
5587
6014
|
const primaryStream = `${entityUrl}/main`;
|
|
5588
6015
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
@@ -6263,6 +6690,8 @@ var WakeRegistry = class {
|
|
|
6263
6690
|
if (eventType === `inbox`) {
|
|
6264
6691
|
const value = event.value;
|
|
6265
6692
|
if (typeof value?.from === `string`) change.from = value.from;
|
|
6693
|
+
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
6694
|
+
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
6266
6695
|
if (`payload` in (value ?? {})) change.payload = value?.payload;
|
|
6267
6696
|
if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
|
|
6268
6697
|
if (typeof value?.message_type === `string`) change.message_type = value.message_type;
|
|
@@ -6674,29 +7103,136 @@ function buildElectricProxyTarget(options) {
|
|
|
6674
7103
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
6675
7104
|
const table = options.incomingUrl.searchParams.get(`table`);
|
|
6676
7105
|
if (table === `entities`) {
|
|
6677
|
-
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
6678
|
-
|
|
7106
|
+
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
7107
|
+
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
7108
|
+
tenantId: options.tenantId,
|
|
7109
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7110
|
+
principalKind: options.principalKind ?? ``,
|
|
7111
|
+
permissionBypass: options.permissionBypass
|
|
7112
|
+
}));
|
|
6679
7113
|
} else if (table === `entity_types`) {
|
|
6680
7114
|
target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
|
|
6681
|
-
|
|
7115
|
+
applyShapeWhere(target, buildSpawnableEntityTypesWhere({
|
|
7116
|
+
tenantId: options.tenantId,
|
|
7117
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7118
|
+
principalKind: options.principalKind ?? ``,
|
|
7119
|
+
permissionBypass: options.permissionBypass
|
|
7120
|
+
}));
|
|
6682
7121
|
} else if (table === `runners`) {
|
|
6683
7122
|
target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
|
|
6684
7123
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
|
|
7124
|
+
} else if (table === `users`) {
|
|
7125
|
+
target.searchParams.set(`columns`, `"tenant_id","id","display_name","email","avatar_url","created_at","updated_at"`);
|
|
7126
|
+
applyTenantShapeWhere(target, options.tenantId);
|
|
7127
|
+
} else if (table === `entity_effective_permissions`) {
|
|
7128
|
+
target.searchParams.set(`columns`, `"tenant_id","id","entity_url","source_entity_url","source_grant_id","permission","subject_kind","subject_value","expires_at","created_at"`);
|
|
7129
|
+
applyShapeWhere(target, buildCurrentPrincipalEntityEffectivePermissionsWhere({
|
|
7130
|
+
tenantId: options.tenantId,
|
|
7131
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7132
|
+
principalKind: options.principalKind ?? ``,
|
|
7133
|
+
permissionBypass: options.permissionBypass
|
|
7134
|
+
}));
|
|
6685
7135
|
} else if (table === `runner_runtime_diagnostics`) {
|
|
6686
7136
|
target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
|
|
6687
7137
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
|
|
6688
7138
|
} else if (table === `entity_dispatch_state`) {
|
|
6689
7139
|
target.searchParams.set(`columns`, `"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`);
|
|
6690
|
-
|
|
7140
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
7141
|
+
tenantId: options.tenantId,
|
|
7142
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7143
|
+
principalKind: options.principalKind ?? ``,
|
|
7144
|
+
permissionBypass: options.permissionBypass
|
|
7145
|
+
}));
|
|
6691
7146
|
} else if (table === `wake_notifications`) {
|
|
6692
7147
|
target.searchParams.set(`columns`, `"tenant_id","wake_id","entity_url","target_type","target_runner_id","target_webhook_url","target_worker_pool_id","runner_wake_stream","runner_wake_stream_offset","notification_public","delivery_status","claim_status","created_at","delivered_at","claimed_at","resolved_at"`);
|
|
6693
|
-
|
|
7148
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
7149
|
+
tenantId: options.tenantId,
|
|
7150
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7151
|
+
principalKind: options.principalKind ?? ``,
|
|
7152
|
+
permissionBypass: options.permissionBypass
|
|
7153
|
+
}));
|
|
6694
7154
|
} else if (table === `consumer_claims`) {
|
|
6695
7155
|
target.searchParams.set(`columns`, `"tenant_id","consumer_id","epoch","wake_id","entity_url","stream_path","runner_id","status","claimed_at","last_heartbeat_at","lease_expires_at","released_at","acked_streams","updated_at"`);
|
|
6696
|
-
|
|
7156
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
7157
|
+
tenantId: options.tenantId,
|
|
7158
|
+
principalUrl: options.principalUrl ?? ``,
|
|
7159
|
+
principalKind: options.principalKind ?? ``,
|
|
7160
|
+
permissionBypass: options.permissionBypass
|
|
7161
|
+
}));
|
|
6697
7162
|
}
|
|
6698
7163
|
return target;
|
|
6699
7164
|
}
|
|
7165
|
+
function buildReadableEntitiesWhere(options) {
|
|
7166
|
+
const tenant = sqlStringLiteral(options.tenantId);
|
|
7167
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
7168
|
+
const principalUrl$1 = sqlStringLiteral(options.principalUrl);
|
|
7169
|
+
const principalKind = sqlStringLiteral(options.principalKind);
|
|
7170
|
+
return [
|
|
7171
|
+
`tenant_id = ${tenant}`,
|
|
7172
|
+
`AND (`,
|
|
7173
|
+
` created_by = ${principalUrl$1}`,
|
|
7174
|
+
` OR url IN (`,
|
|
7175
|
+
` SELECT entity_url`,
|
|
7176
|
+
` FROM entity_effective_permissions`,
|
|
7177
|
+
` WHERE tenant_id = ${tenant}`,
|
|
7178
|
+
` AND permission IN ('read', 'manage')`,
|
|
7179
|
+
` AND (`,
|
|
7180
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
7181
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
7182
|
+
` )`,
|
|
7183
|
+
` )`,
|
|
7184
|
+
`)`
|
|
7185
|
+
].join(`\n`);
|
|
7186
|
+
}
|
|
7187
|
+
function buildReadableEntityUrlWhere(options) {
|
|
7188
|
+
const tenant = sqlStringLiteral(options.tenantId);
|
|
7189
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
7190
|
+
return [
|
|
7191
|
+
`tenant_id = ${tenant}`,
|
|
7192
|
+
`AND entity_url IN (`,
|
|
7193
|
+
` SELECT url`,
|
|
7194
|
+
` FROM entities`,
|
|
7195
|
+
` WHERE ${indentWhere(buildReadableEntitiesWhere(options), ` `).trimStart()}`,
|
|
7196
|
+
`)`
|
|
7197
|
+
].join(`\n`);
|
|
7198
|
+
}
|
|
7199
|
+
function buildCurrentPrincipalEntityEffectivePermissionsWhere(options) {
|
|
7200
|
+
const tenant = sqlStringLiteral(options.tenantId);
|
|
7201
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
7202
|
+
const principalUrl$1 = sqlStringLiteral(options.principalUrl);
|
|
7203
|
+
const principalKind = sqlStringLiteral(options.principalKind);
|
|
7204
|
+
return [
|
|
7205
|
+
`tenant_id = ${tenant}`,
|
|
7206
|
+
`AND (`,
|
|
7207
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
7208
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
7209
|
+
`)`,
|
|
7210
|
+
`AND entity_url IN (`,
|
|
7211
|
+
` SELECT url`,
|
|
7212
|
+
` FROM entities`,
|
|
7213
|
+
` WHERE ${buildReadableEntitiesWhere(options)}`,
|
|
7214
|
+
`)`
|
|
7215
|
+
].join(`\n`);
|
|
7216
|
+
}
|
|
7217
|
+
function buildSpawnableEntityTypesWhere(options) {
|
|
7218
|
+
const tenant = sqlStringLiteral(options.tenantId);
|
|
7219
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
7220
|
+
const principalUrl$1 = sqlStringLiteral(options.principalUrl);
|
|
7221
|
+
const principalKind = sqlStringLiteral(options.principalKind);
|
|
7222
|
+
return [
|
|
7223
|
+
`tenant_id = ${tenant}`,
|
|
7224
|
+
`AND name IN (`,
|
|
7225
|
+
` SELECT entity_type`,
|
|
7226
|
+
` FROM entity_type_permission_grants`,
|
|
7227
|
+
` WHERE tenant_id = ${tenant}`,
|
|
7228
|
+
` AND permission IN ('spawn', 'manage')`,
|
|
7229
|
+
` AND (`,
|
|
7230
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
7231
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
7232
|
+
` )`,
|
|
7233
|
+
`)`
|
|
7234
|
+
].join(`\n`);
|
|
7235
|
+
}
|
|
6700
7236
|
async function forwardFetchRequest(options) {
|
|
6701
7237
|
const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting, options.durableStreamsUrl);
|
|
6702
7238
|
const routingInput = {
|
|
@@ -6731,13 +7267,170 @@ function decodeJsonObject(body) {
|
|
|
6731
7267
|
return null;
|
|
6732
7268
|
}
|
|
6733
7269
|
function applyTenantShapeWhere(target, tenantId, extraConditions = []) {
|
|
6734
|
-
|
|
7270
|
+
applyShapeWhere(target, [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(` AND `));
|
|
7271
|
+
}
|
|
7272
|
+
function applyShapeWhere(target, enforcedWhere) {
|
|
6735
7273
|
const existingWhere = target.searchParams.get(`where`);
|
|
6736
|
-
target.searchParams.set(`where`, existingWhere ? `${
|
|
7274
|
+
target.searchParams.set(`where`, existingWhere ? `${enforcedWhere} AND (${existingWhere})` : enforcedWhere);
|
|
6737
7275
|
}
|
|
6738
7276
|
function sqlStringLiteral(value) {
|
|
6739
7277
|
return `'${value.replace(/'/g, `''`)}'`;
|
|
6740
7278
|
}
|
|
7279
|
+
function indentWhere(where, prefix) {
|
|
7280
|
+
return where.split(`\n`).map((line) => `${prefix}${line}`).join(`\n`);
|
|
7281
|
+
}
|
|
7282
|
+
|
|
7283
|
+
//#endregion
|
|
7284
|
+
//#region src/permissions.ts
|
|
7285
|
+
const authzDecisionCache = new WeakMap();
|
|
7286
|
+
function principalSubject(principal) {
|
|
7287
|
+
return {
|
|
7288
|
+
principalUrl: principal.url,
|
|
7289
|
+
principalKind: principal.kind
|
|
7290
|
+
};
|
|
7291
|
+
}
|
|
7292
|
+
function isPermissionBypassPrincipal(ctx) {
|
|
7293
|
+
return isBuiltInSystemPrincipalUrl(ctx.principal.url);
|
|
7294
|
+
}
|
|
7295
|
+
async function canAccessEntity(ctx, entity, permission, request) {
|
|
7296
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
7297
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
7298
|
+
const builtInAllowed = entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal));
|
|
7299
|
+
return await applyAuthorizationHook(ctx, {
|
|
7300
|
+
verb: permission,
|
|
7301
|
+
resourceKey: `entity:${entity.url}`,
|
|
7302
|
+
resource: {
|
|
7303
|
+
kind: `entity`,
|
|
7304
|
+
entity
|
|
7305
|
+
},
|
|
7306
|
+
builtInAllowed,
|
|
7307
|
+
request
|
|
7308
|
+
});
|
|
7309
|
+
}
|
|
7310
|
+
async function canAccessEntityType(ctx, entityType, permission, request) {
|
|
7311
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
7312
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
7313
|
+
const builtInAllowed = await ctx.entityManager.registry.hasEntityTypePermission(entityType.name, permission, principalSubject(ctx.principal));
|
|
7314
|
+
return await applyAuthorizationHook(ctx, {
|
|
7315
|
+
verb: permission,
|
|
7316
|
+
resourceKey: `entity_type:${entityType.name}`,
|
|
7317
|
+
resource: {
|
|
7318
|
+
kind: `entity_type`,
|
|
7319
|
+
entityType
|
|
7320
|
+
},
|
|
7321
|
+
builtInAllowed,
|
|
7322
|
+
request
|
|
7323
|
+
});
|
|
7324
|
+
}
|
|
7325
|
+
async function canRegisterEntityType(ctx, input, request) {
|
|
7326
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
7327
|
+
return await applyAuthorizationHook(ctx, {
|
|
7328
|
+
verb: `manage`,
|
|
7329
|
+
resourceKey: `entity_type_registration:${input.name}`,
|
|
7330
|
+
resource: {
|
|
7331
|
+
kind: `entity_type_registration`,
|
|
7332
|
+
entityTypeName: input.name
|
|
7333
|
+
},
|
|
7334
|
+
builtInAllowed: true,
|
|
7335
|
+
request
|
|
7336
|
+
});
|
|
7337
|
+
}
|
|
7338
|
+
async function canAccessSharedState(ctx, sharedStateId, permission, request, ownerEntityUrl) {
|
|
7339
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
7340
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
7341
|
+
const storedLinkedEntityUrls = await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(sharedStateId);
|
|
7342
|
+
const bootstrapEntityUrls = storedLinkedEntityUrls.length === 0 && ownerEntityUrl ? [ownerEntityUrl] : [];
|
|
7343
|
+
const linkedEntityUrls = [...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls])];
|
|
7344
|
+
for (const entityUrl of linkedEntityUrls) {
|
|
7345
|
+
const entity = await ctx.entityManager.registry.getEntity(entityUrl);
|
|
7346
|
+
if (!entity) continue;
|
|
7347
|
+
if (entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal))) return await applyAuthorizationHook(ctx, {
|
|
7348
|
+
verb: permission,
|
|
7349
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
7350
|
+
resource: {
|
|
7351
|
+
kind: `shared_state`,
|
|
7352
|
+
sharedStateId,
|
|
7353
|
+
linkedEntityUrls
|
|
7354
|
+
},
|
|
7355
|
+
builtInAllowed: true,
|
|
7356
|
+
request
|
|
7357
|
+
});
|
|
7358
|
+
}
|
|
7359
|
+
return await applyAuthorizationHook(ctx, {
|
|
7360
|
+
verb: permission,
|
|
7361
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
7362
|
+
resource: {
|
|
7363
|
+
kind: `shared_state`,
|
|
7364
|
+
sharedStateId,
|
|
7365
|
+
linkedEntityUrls
|
|
7366
|
+
},
|
|
7367
|
+
builtInAllowed: false,
|
|
7368
|
+
request
|
|
7369
|
+
});
|
|
7370
|
+
}
|
|
7371
|
+
async function applyAuthorizationHook(ctx, input) {
|
|
7372
|
+
const hook = ctx.authorizeRequest;
|
|
7373
|
+
if (!hook) return input.builtInAllowed;
|
|
7374
|
+
const cacheKey = [
|
|
7375
|
+
ctx.service,
|
|
7376
|
+
ctx.principal.url,
|
|
7377
|
+
input.verb,
|
|
7378
|
+
input.resourceKey
|
|
7379
|
+
].join(`|`);
|
|
7380
|
+
const cached = getCachedDecision(hook, cacheKey);
|
|
7381
|
+
if (cached) return cached.decision === `allow`;
|
|
7382
|
+
let decision;
|
|
7383
|
+
try {
|
|
7384
|
+
decision = await hook({
|
|
7385
|
+
tenant: ctx.service,
|
|
7386
|
+
principal: ctx.principal,
|
|
7387
|
+
verb: input.verb,
|
|
7388
|
+
resource: input.resource,
|
|
7389
|
+
request: input.request ? requestMetadata(input.request) : void 0,
|
|
7390
|
+
builtInAllowed: input.builtInAllowed
|
|
7391
|
+
});
|
|
7392
|
+
} catch (error) {
|
|
7393
|
+
serverLog.warn(`[agent-server] authorization hook failed:`, error);
|
|
7394
|
+
return false;
|
|
7395
|
+
}
|
|
7396
|
+
cacheDecision(hook, cacheKey, decision);
|
|
7397
|
+
return decision.decision === `allow`;
|
|
7398
|
+
}
|
|
7399
|
+
function getCachedDecision(hook, cacheKey) {
|
|
7400
|
+
const cache = authzDecisionCache.get(hook);
|
|
7401
|
+
const entry = cache?.get(cacheKey);
|
|
7402
|
+
if (!entry) return null;
|
|
7403
|
+
if (entry.expiresAt <= Date.now()) {
|
|
7404
|
+
cache?.delete(cacheKey);
|
|
7405
|
+
return null;
|
|
7406
|
+
}
|
|
7407
|
+
return { decision: entry.decision };
|
|
7408
|
+
}
|
|
7409
|
+
function cacheDecision(hook, cacheKey, decision) {
|
|
7410
|
+
if (!decision.expires_at) return;
|
|
7411
|
+
const expiresAt = Date.parse(decision.expires_at);
|
|
7412
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
|
7413
|
+
let cache = authzDecisionCache.get(hook);
|
|
7414
|
+
if (!cache) {
|
|
7415
|
+
cache = new Map();
|
|
7416
|
+
authzDecisionCache.set(hook, cache);
|
|
7417
|
+
}
|
|
7418
|
+
cache.set(cacheKey, {
|
|
7419
|
+
decision: decision.decision,
|
|
7420
|
+
expiresAt
|
|
7421
|
+
});
|
|
7422
|
+
}
|
|
7423
|
+
function requestMetadata(request) {
|
|
7424
|
+
const headers = {};
|
|
7425
|
+
request.headers.forEach((value, key) => {
|
|
7426
|
+
headers[key] = value;
|
|
7427
|
+
});
|
|
7428
|
+
return {
|
|
7429
|
+
method: request.method,
|
|
7430
|
+
url: request.url,
|
|
7431
|
+
headers
|
|
7432
|
+
};
|
|
7433
|
+
}
|
|
6741
7434
|
|
|
6742
7435
|
//#endregion
|
|
6743
7436
|
//#region src/webhook-signing.ts
|
|
@@ -6829,6 +7522,7 @@ const subscriptionControlActions = [
|
|
|
6829
7522
|
`ack`,
|
|
6830
7523
|
`release`
|
|
6831
7524
|
];
|
|
7525
|
+
const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`;
|
|
6832
7526
|
const durableStreamsRouter = Router();
|
|
6833
7527
|
durableStreamsRouter.put(`/__ds/subscriptions/:subscriptionId`, putSubscriptionBase);
|
|
6834
7528
|
durableStreamsRouter.get(`/__ds/subscriptions/:subscriptionId`, getSubscriptionBase);
|
|
@@ -7046,6 +7740,8 @@ async function webhookJwks(_request, ctx) {
|
|
|
7046
7740
|
});
|
|
7047
7741
|
}
|
|
7048
7742
|
async function streamAppend(request, ctx) {
|
|
7743
|
+
const auth = await authorizeDurableStreamAccess(request, ctx);
|
|
7744
|
+
if (auth) return auth;
|
|
7049
7745
|
return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
|
|
7050
7746
|
request: {
|
|
7051
7747
|
method: req.method,
|
|
@@ -7062,8 +7758,9 @@ async function streamAppend(request, ctx) {
|
|
|
7062
7758
|
}));
|
|
7063
7759
|
}
|
|
7064
7760
|
async function proxyPassThrough(request, ctx) {
|
|
7761
|
+
const auth = await authorizeDurableStreamAccess(request, ctx);
|
|
7762
|
+
if (auth) return auth;
|
|
7065
7763
|
const streamPath = new URL(request.url).pathname;
|
|
7066
|
-
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
|
|
7067
7764
|
const upstream = await forwardToDurableStreams(ctx, request);
|
|
7068
7765
|
const method = request.method.toUpperCase();
|
|
7069
7766
|
const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
|
|
@@ -7074,6 +7771,51 @@ async function proxyPassThrough(request, ctx) {
|
|
|
7074
7771
|
await endTrackedRead?.();
|
|
7075
7772
|
}
|
|
7076
7773
|
}
|
|
7774
|
+
async function authorizeDurableStreamAccess(request, ctx) {
|
|
7775
|
+
const method = request.method.toUpperCase();
|
|
7776
|
+
const streamPath = new URL(request.url).pathname;
|
|
7777
|
+
if (method === `GET` || method === `HEAD`) {
|
|
7778
|
+
const registry = ctx.entityManager?.registry;
|
|
7779
|
+
const entity = registry?.getEntityByStream ? await registry.getEntityByStream(streamPath) : null;
|
|
7780
|
+
if (entity) {
|
|
7781
|
+
if (await canAccessEntity(ctx, entity, `read`, request)) return void 0;
|
|
7782
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${entity.url}`);
|
|
7783
|
+
}
|
|
7784
|
+
const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath);
|
|
7785
|
+
if (attachmentEntityUrl) {
|
|
7786
|
+
const attachmentEntity = registry?.getEntity ? await registry.getEntity(attachmentEntityUrl) : null;
|
|
7787
|
+
if (!attachmentEntity) return apiError(404, ErrCodeNotFound, `Entity not found`);
|
|
7788
|
+
if (await canAccessEntity(ctx, attachmentEntity, `read`, request)) return void 0;
|
|
7789
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${attachmentEntity.url}`);
|
|
7790
|
+
}
|
|
7791
|
+
}
|
|
7792
|
+
const sharedStateId = sharedStateIdFromPath(streamPath);
|
|
7793
|
+
if (!sharedStateId) return void 0;
|
|
7794
|
+
if (method === `GET` || method === `HEAD`) {
|
|
7795
|
+
if (await canAccessSharedState(ctx, sharedStateId, `read`, request)) return void 0;
|
|
7796
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read shared state`);
|
|
7797
|
+
}
|
|
7798
|
+
if (method === `PUT` || method === `POST`) {
|
|
7799
|
+
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
7800
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
|
|
7801
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
7802
|
+
}
|
|
7803
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
7804
|
+
}
|
|
7805
|
+
function entityUrlFromAttachmentStreamPath(path$1) {
|
|
7806
|
+
const match = path$1.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/);
|
|
7807
|
+
if (!match) return null;
|
|
7808
|
+
return `/${match[1]}/${match[2]}`;
|
|
7809
|
+
}
|
|
7810
|
+
function sharedStateIdFromPath(path$1) {
|
|
7811
|
+
const match = path$1.match(/^\/_electric\/shared-state\/([^/]+)$/);
|
|
7812
|
+
if (!match) return null;
|
|
7813
|
+
try {
|
|
7814
|
+
return decodeURIComponent(match[1]);
|
|
7815
|
+
} catch {
|
|
7816
|
+
return match[1];
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7077
7819
|
|
|
7078
7820
|
//#endregion
|
|
7079
7821
|
//#region src/routing/electric-proxy-router.ts
|
|
@@ -7081,12 +7823,15 @@ const electricProxyRouter = Router({ base: `/_electric/electric` });
|
|
|
7081
7823
|
electricProxyRouter.get(`/*`, proxyElectric);
|
|
7082
7824
|
async function proxyElectric(request, ctx) {
|
|
7083
7825
|
if (!ctx.electricUrl) return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`);
|
|
7826
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
7084
7827
|
const target = buildElectricProxyTarget({
|
|
7085
7828
|
incomingUrl: new URL(request.url),
|
|
7086
7829
|
electricUrl: ctx.electricUrl,
|
|
7087
7830
|
electricSecret: ctx.electricSecret,
|
|
7088
7831
|
tenantId: ctx.service,
|
|
7089
|
-
principalUrl: ctx.principal.url
|
|
7832
|
+
principalUrl: ctx.principal.url,
|
|
7833
|
+
principalKind: ctx.principal.kind,
|
|
7834
|
+
permissionBypass: isPermissionBypassPrincipal(ctx)
|
|
7090
7835
|
});
|
|
7091
7836
|
const headers = new Headers(request.headers);
|
|
7092
7837
|
headers.delete(`host`);
|
|
@@ -7145,6 +7890,27 @@ const wakeConditionSchema = Type.Union([Type.Literal(`runFinished`), Type.Object
|
|
|
7145
7890
|
Type.Literal(`delete`)
|
|
7146
7891
|
])))
|
|
7147
7892
|
})]);
|
|
7893
|
+
const permissionSubjectSchema = Type.Object({
|
|
7894
|
+
subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
|
|
7895
|
+
subject_value: Type.String()
|
|
7896
|
+
}, { additionalProperties: false });
|
|
7897
|
+
const entityPermissionSchema = Type.Union([
|
|
7898
|
+
Type.Literal(`read`),
|
|
7899
|
+
Type.Literal(`write`),
|
|
7900
|
+
Type.Literal(`delete`),
|
|
7901
|
+
Type.Literal(`signal`),
|
|
7902
|
+
Type.Literal(`fork`),
|
|
7903
|
+
Type.Literal(`schedule`),
|
|
7904
|
+
Type.Literal(`spawn`),
|
|
7905
|
+
Type.Literal(`manage`)
|
|
7906
|
+
]);
|
|
7907
|
+
const entityPermissionGrantInputSchema = Type.Object({
|
|
7908
|
+
...permissionSubjectSchema.properties,
|
|
7909
|
+
permission: entityPermissionSchema,
|
|
7910
|
+
propagation: Type.Optional(Type.Union([Type.Literal(`self`), Type.Literal(`descendants`)])),
|
|
7911
|
+
copy_to_children: Type.Optional(Type.Boolean()),
|
|
7912
|
+
expires_at: Type.Optional(Type.String())
|
|
7913
|
+
}, { additionalProperties: false });
|
|
7148
7914
|
const spawnBodySchema = Type.Object({
|
|
7149
7915
|
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
7150
7916
|
tags: Type.Optional(stringRecordSchema$1),
|
|
@@ -7152,6 +7918,7 @@ const spawnBodySchema = Type.Object({
|
|
|
7152
7918
|
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
7153
7919
|
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
7154
7920
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
7921
|
+
grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
|
|
7155
7922
|
wake: Type.Optional(Type.Object({
|
|
7156
7923
|
subscriberUrl: Type.String(),
|
|
7157
7924
|
condition: wakeConditionSchema,
|
|
@@ -7173,8 +7940,22 @@ const sendBodySchema = Type.Object({
|
|
|
7173
7940
|
])),
|
|
7174
7941
|
position: Type.Optional(Type.String()),
|
|
7175
7942
|
afterMs: Type.Optional(Type.Number()),
|
|
7176
|
-
from: Type.Optional(Type.String())
|
|
7943
|
+
from: Type.Optional(Type.String()),
|
|
7944
|
+
from_principal: Type.Optional(Type.String()),
|
|
7945
|
+
from_agent: Type.Optional(Type.String())
|
|
7177
7946
|
});
|
|
7947
|
+
function agentUrlForPrincipal(principal) {
|
|
7948
|
+
if (principal.kind === `agent`) return `/${principal.id}`;
|
|
7949
|
+
if (principal.key.startsWith(`entity:`)) return `/${principal.key.slice(`entity:`.length)}`;
|
|
7950
|
+
return null;
|
|
7951
|
+
}
|
|
7952
|
+
function agentUrlPath(value) {
|
|
7953
|
+
try {
|
|
7954
|
+
return new URL(value).pathname;
|
|
7955
|
+
} catch {
|
|
7956
|
+
return value;
|
|
7957
|
+
}
|
|
7958
|
+
}
|
|
7178
7959
|
const inboxMessageBodySchema = Type.Object({
|
|
7179
7960
|
payload: Type.Optional(Type.Unknown()),
|
|
7180
7961
|
position: Type.Optional(Type.String()),
|
|
@@ -7253,24 +8034,27 @@ const attachmentSubjectTypes = new Set([
|
|
|
7253
8034
|
]);
|
|
7254
8035
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
7255
8036
|
entitiesRouter.get(`/`, listEntities);
|
|
7256
|
-
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
|
|
7257
|
-
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
7258
|
-
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
7259
|
-
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
7260
|
-
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
7261
|
-
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
7262
|
-
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
|
|
7263
|
-
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
|
|
7264
|
-
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
|
|
7265
|
-
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
7266
|
-
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
7267
|
-
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
|
|
7268
|
-
entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), setTag);
|
|
7269
|
-
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, deleteTag);
|
|
7270
|
-
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
|
|
7271
|
-
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
|
|
7272
|
-
entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
|
|
7273
|
-
entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
|
|
8037
|
+
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), withSpawnPermission, spawnEntity);
|
|
8038
|
+
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), getEntity);
|
|
8039
|
+
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), headEntity);
|
|
8040
|
+
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
8041
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
8042
|
+
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
8043
|
+
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
8044
|
+
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
8045
|
+
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
8046
|
+
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), withEntityPermission(`write`), updateInboxMessage);
|
|
8047
|
+
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withEntityPermission(`write`), deleteInboxMessage);
|
|
8048
|
+
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), withEntityPermission(`fork`), forkEntity);
|
|
8049
|
+
entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), withEntityPermission(`write`), setTag);
|
|
8050
|
+
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
|
|
8051
|
+
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
|
|
8052
|
+
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
|
|
8053
|
+
entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
|
|
8054
|
+
entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
|
|
8055
|
+
entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
|
|
8056
|
+
entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
|
|
8057
|
+
entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
|
|
7274
8058
|
function entityUrlFromSegments(type, instanceId) {
|
|
7275
8059
|
if (!type || !instanceId) return null;
|
|
7276
8060
|
if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
|
|
@@ -7369,6 +8153,17 @@ function rejectPrincipalEntityMutation(request, action) {
|
|
|
7369
8153
|
if (entity.type !== `principal`) return void 0;
|
|
7370
8154
|
return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be ${action}`);
|
|
7371
8155
|
}
|
|
8156
|
+
function parseExpiresAt$1(value) {
|
|
8157
|
+
if (value === void 0) return void 0;
|
|
8158
|
+
const expiresAt = new Date(value);
|
|
8159
|
+
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
8160
|
+
return expiresAt;
|
|
8161
|
+
}
|
|
8162
|
+
function parseGrantId$1(request) {
|
|
8163
|
+
const grantId = Number.parseInt(String(request.params.grantId), 10);
|
|
8164
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
|
|
8165
|
+
return grantId;
|
|
8166
|
+
}
|
|
7372
8167
|
async function withExistingEntity(request, ctx) {
|
|
7373
8168
|
const entityUrl = entityUrlFromSegments(request.params.type, request.params.instanceId);
|
|
7374
8169
|
if (!entityUrl) return void 0;
|
|
@@ -7399,17 +8194,76 @@ async function withSpawnableEntityType(request, ctx) {
|
|
|
7399
8194
|
if (request.params.type === `principal`) return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be spawned directly`);
|
|
7400
8195
|
const entityType = await ctx.entityManager.registry.getEntityType(request.params.type);
|
|
7401
8196
|
if (!entityType) return apiError(404, ErrCodeUnknownEntityType, `Entity type "${request.params.type}" not found`);
|
|
8197
|
+
request.spawnRoute = { entityType };
|
|
7402
8198
|
return void 0;
|
|
7403
8199
|
}
|
|
8200
|
+
function withEntityPermission(permission) {
|
|
8201
|
+
return async (request, ctx) => {
|
|
8202
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
8203
|
+
if (await canAccessEntity(ctx, entity, permission, request)) return void 0;
|
|
8204
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to ${permission} ${entity.url}`);
|
|
8205
|
+
};
|
|
8206
|
+
}
|
|
8207
|
+
async function withSpawnPermission(request, ctx) {
|
|
8208
|
+
const parsed = routeBody(request);
|
|
8209
|
+
const entityType = request.spawnRoute?.entityType;
|
|
8210
|
+
if (!entityType) throw new Error(`spawnable entity type middleware did not run`);
|
|
8211
|
+
if (!await canAccessEntityType(ctx, entityType, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
|
|
8212
|
+
if (!parsed.parent) return void 0;
|
|
8213
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
|
|
8214
|
+
if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
|
|
8215
|
+
if (await canAccessEntity(ctx, parent, `spawn`, request)) return await validateParentedSpawnGrants(request, ctx, parent, parsed);
|
|
8216
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
|
|
8217
|
+
}
|
|
8218
|
+
async function validateParentedSpawnGrants(request, ctx, parent, parsed) {
|
|
8219
|
+
const needsParentManage = (parsed.grants ?? []).some(requiresParentManageForInitialGrant);
|
|
8220
|
+
if (!needsParentManage) return void 0;
|
|
8221
|
+
if (await canAccessEntity(ctx, parent, `manage`, request)) return void 0;
|
|
8222
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to delegate broad grants from ${parent.url}`);
|
|
8223
|
+
}
|
|
8224
|
+
function requiresParentManageForInitialGrant(grant) {
|
|
8225
|
+
return grant.permission === `manage` || grant.subject_kind === `principal_kind` || grant.propagation === `descendants` || grant.copy_to_children === true;
|
|
8226
|
+
}
|
|
7404
8227
|
async function listEntities({ query }, ctx) {
|
|
7405
8228
|
const { entities: entities$1 } = await ctx.entityManager.registry.listEntities({
|
|
7406
8229
|
type: firstQueryValue$1(query.type),
|
|
7407
8230
|
status: firstQueryValue$1(query.status),
|
|
7408
8231
|
parent: firstQueryValue$1(query.parent),
|
|
7409
|
-
created_by: firstQueryValue$1(query.created_by)
|
|
8232
|
+
created_by: firstQueryValue$1(query.created_by),
|
|
8233
|
+
readableBy: {
|
|
8234
|
+
...principalSubject(ctx.principal),
|
|
8235
|
+
bypass: isPermissionBypassPrincipal(ctx)
|
|
8236
|
+
}
|
|
7410
8237
|
});
|
|
7411
8238
|
return json(entities$1.map((entity) => toPublicEntity(entity)));
|
|
7412
8239
|
}
|
|
8240
|
+
async function listEntityPermissionGrants(request, ctx) {
|
|
8241
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8242
|
+
const grants = await ctx.entityManager.registry.listEntityPermissionGrants(entityUrl);
|
|
8243
|
+
return json({ grants });
|
|
8244
|
+
}
|
|
8245
|
+
async function createEntityPermissionGrant(request, ctx) {
|
|
8246
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8247
|
+
const parsed = routeBody(request);
|
|
8248
|
+
const grant = await ctx.entityManager.registry.createEntityPermissionGrant({
|
|
8249
|
+
entityUrl,
|
|
8250
|
+
permission: parsed.permission,
|
|
8251
|
+
subjectKind: parsed.subject_kind,
|
|
8252
|
+
subjectValue: parsed.subject_value,
|
|
8253
|
+
propagation: parsed.propagation,
|
|
8254
|
+
copyToChildren: parsed.copy_to_children,
|
|
8255
|
+
expiresAt: parseExpiresAt$1(parsed.expires_at),
|
|
8256
|
+
createdBy: ctx.principal.url
|
|
8257
|
+
});
|
|
8258
|
+
await ctx.entityBridgeManager.onEntityChanged(entityUrl);
|
|
8259
|
+
return json(grant, { status: 201 });
|
|
8260
|
+
}
|
|
8261
|
+
async function deleteEntityPermissionGrant(request, ctx) {
|
|
8262
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
8263
|
+
const deleted = await ctx.entityManager.registry.deleteEntityPermissionGrant(entityUrl, parseGrantId$1(request));
|
|
8264
|
+
if (deleted) await ctx.entityBridgeManager.onEntityChanged(entityUrl);
|
|
8265
|
+
return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
|
|
8266
|
+
}
|
|
7413
8267
|
async function upsertSchedule(request, ctx) {
|
|
7414
8268
|
const principalMutationError = rejectPrincipalEntityMutation(request, `scheduled`);
|
|
7415
8269
|
if (principalMutationError) return principalMutationError;
|
|
@@ -7515,6 +8369,7 @@ async function forkEntity(request, ctx) {
|
|
|
7515
8369
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
7516
8370
|
rootInstanceId: parsed.instance_id,
|
|
7517
8371
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
8372
|
+
createdBy: ctx.principal.url,
|
|
7518
8373
|
...parsed.fork_pointer && { forkPointer: {
|
|
7519
8374
|
offset: parsed.fork_pointer.offset,
|
|
7520
8375
|
subOffset: parsed.fork_pointer.sub_offset
|
|
@@ -7530,26 +8385,27 @@ async function sendEntity(request, ctx) {
|
|
|
7530
8385
|
const parsed = routeBody(request);
|
|
7531
8386
|
const principal = ctx.principal;
|
|
7532
8387
|
if (parsed.from !== void 0 && parsed.from !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from must match Electric-Principal`);
|
|
8388
|
+
if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
|
|
8389
|
+
if (parsed.from_agent !== void 0) {
|
|
8390
|
+
const principalAgentUrl = agentUrlForPrincipal(principal);
|
|
8391
|
+
if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
|
|
8392
|
+
}
|
|
7533
8393
|
await ctx.entityManager.ensurePrincipal(principal);
|
|
7534
8394
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
7535
8395
|
const dispatchEntity = entity.dispatch_policy ? entity : await backfillEntityDispatchPolicy(ctx, entity);
|
|
7536
8396
|
await linkEntityDispatchSubscription(ctx, dispatchEntity);
|
|
7537
|
-
|
|
7538
|
-
from: principal.url,
|
|
7539
|
-
payload: parsed.payload,
|
|
7540
|
-
key: parsed.key,
|
|
7541
|
-
type: parsed.type,
|
|
7542
|
-
mode: parsed.mode,
|
|
7543
|
-
position: parsed.position
|
|
7544
|
-
}, new Date(Date.now() + parsed.afterMs));
|
|
7545
|
-
else await ctx.entityManager.send(entityUrl, {
|
|
8397
|
+
const sendReq = {
|
|
7546
8398
|
from: principal.url,
|
|
8399
|
+
from_principal: principal.url,
|
|
8400
|
+
from_agent: parsed.from_agent,
|
|
7547
8401
|
payload: parsed.payload,
|
|
7548
8402
|
key: parsed.key,
|
|
7549
8403
|
type: parsed.type,
|
|
7550
8404
|
mode: parsed.mode,
|
|
7551
8405
|
position: parsed.position
|
|
7552
|
-
}
|
|
8406
|
+
};
|
|
8407
|
+
if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
8408
|
+
else await ctx.entityManager.send(entityUrl, sendReq);
|
|
7553
8409
|
return status(204);
|
|
7554
8410
|
}
|
|
7555
8411
|
async function createAttachment(request, ctx) {
|
|
@@ -7621,6 +8477,17 @@ async function spawnEntity(request, ctx) {
|
|
|
7621
8477
|
wake: parsed.wake,
|
|
7622
8478
|
created_by: principal.url
|
|
7623
8479
|
});
|
|
8480
|
+
if (parsed.parent) await ctx.entityManager.registry.copyEntityPermissionGrantsForSpawn(parsed.parent, entity.url, principal.url);
|
|
8481
|
+
for (const grant of parsed.grants ?? []) await ctx.entityManager.registry.createEntityPermissionGrant({
|
|
8482
|
+
entityUrl: entity.url,
|
|
8483
|
+
permission: grant.permission,
|
|
8484
|
+
subjectKind: grant.subject_kind,
|
|
8485
|
+
subjectValue: grant.subject_value,
|
|
8486
|
+
propagation: grant.propagation,
|
|
8487
|
+
copyToChildren: grant.copy_to_children,
|
|
8488
|
+
expiresAt: parseExpiresAt$1(grant.expires_at),
|
|
8489
|
+
createdBy: principal.url
|
|
8490
|
+
});
|
|
7624
8491
|
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
7625
8492
|
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
7626
8493
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
@@ -7672,6 +8539,12 @@ async function signalEntity(request, ctx) {
|
|
|
7672
8539
|
//#region src/routing/entity-types-router.ts
|
|
7673
8540
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
7674
8541
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
8542
|
+
const typePermissionGrantInputSchema = Type.Object({
|
|
8543
|
+
subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
|
|
8544
|
+
subject_value: Type.String(),
|
|
8545
|
+
permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
|
|
8546
|
+
expires_at: Type.Optional(Type.String())
|
|
8547
|
+
}, { additionalProperties: false });
|
|
7675
8548
|
const registerEntityTypeBodySchema = Type.Object({
|
|
7676
8549
|
name: Type.Optional(Type.String()),
|
|
7677
8550
|
description: Type.Optional(Type.String()),
|
|
@@ -7679,7 +8552,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
7679
8552
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
7680
8553
|
state_schemas: Type.Optional(schemaMapSchema),
|
|
7681
8554
|
serve_endpoint: Type.Optional(Type.String()),
|
|
7682
|
-
default_dispatch_policy: Type.Optional(dispatchPolicySchema)
|
|
8555
|
+
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
8556
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
7683
8557
|
}, { additionalProperties: false });
|
|
7684
8558
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
7685
8559
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -7687,20 +8561,56 @@ const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
|
7687
8561
|
}, { additionalProperties: false });
|
|
7688
8562
|
const entityTypesRouter = Router({ base: `/_electric/entity-types` });
|
|
7689
8563
|
entityTypesRouter.get(`/`, listEntityTypes);
|
|
7690
|
-
entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), registerEntityType);
|
|
7691
|
-
entityTypesRouter.patch(`/:name/schemas`, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
|
|
7692
|
-
entityTypesRouter.get(`/:name`, getEntityType);
|
|
7693
|
-
entityTypesRouter.delete(`/:name`, deleteEntityType);
|
|
8564
|
+
entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), withEntityTypeRegistrationPermission, registerEntityType);
|
|
8565
|
+
entityTypesRouter.patch(`/:name/schemas`, withExistingEntityType, withEntityTypeManagePermission, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
|
|
8566
|
+
entityTypesRouter.get(`/:name`, withExistingEntityType, withEntityTypeSpawnPermission, getEntityType);
|
|
8567
|
+
entityTypesRouter.delete(`/:name`, withExistingEntityType, withEntityTypeManagePermission, deleteEntityType);
|
|
8568
|
+
entityTypesRouter.get(`/:name/grants`, withExistingEntityType, withEntityTypeManagePermission, listTypePermissionGrants);
|
|
8569
|
+
entityTypesRouter.post(`/:name/grants`, withExistingEntityType, withSchema(typePermissionGrantInputSchema), withEntityTypeManagePermission, createTypePermissionGrant);
|
|
8570
|
+
entityTypesRouter.delete(`/:name/grants/:grantId`, withExistingEntityType, withEntityTypeManagePermission, deleteTypePermissionGrant);
|
|
7694
8571
|
async function registerEntityType(request, ctx) {
|
|
7695
8572
|
const parsed = routeBody(request);
|
|
7696
8573
|
const normalized = normalizeEntityTypeRequest(parsed);
|
|
7697
8574
|
if (normalized.serve_endpoint && !normalized.description && !normalized.creation_schema) return await discoverServeEndpoint(ctx, normalized);
|
|
7698
8575
|
const entityType = await ctx.entityManager.registerEntityType(normalized);
|
|
8576
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, normalized);
|
|
7699
8577
|
return json(toPublicEntityType(entityType), { status: 201 });
|
|
7700
8578
|
}
|
|
7701
8579
|
async function listEntityTypes(_request, ctx) {
|
|
7702
8580
|
const entityTypes$1 = await ctx.entityManager.registry.listEntityTypes();
|
|
7703
|
-
|
|
8581
|
+
const visible = [];
|
|
8582
|
+
for (const entityType of entityTypes$1) if (await canAccessEntityType(ctx, entityType, `spawn`)) visible.push(entityType);
|
|
8583
|
+
return json(visible.map((entityType) => toPublicEntityType(entityType)));
|
|
8584
|
+
}
|
|
8585
|
+
async function withExistingEntityType(request, ctx) {
|
|
8586
|
+
const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
|
|
8587
|
+
if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
|
|
8588
|
+
request.entityTypeRoute = { entityType };
|
|
8589
|
+
return void 0;
|
|
8590
|
+
}
|
|
8591
|
+
async function withEntityTypeManagePermission(request, ctx) {
|
|
8592
|
+
const entityType = request.entityTypeRoute?.entityType;
|
|
8593
|
+
if (!entityType) throw new Error(`entity type middleware did not run`);
|
|
8594
|
+
if (await canAccessEntityType(ctx, entityType, `manage`, request)) return void 0;
|
|
8595
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${entityType.name}`);
|
|
8596
|
+
}
|
|
8597
|
+
async function withEntityTypeSpawnPermission(request, ctx) {
|
|
8598
|
+
const entityType = request.entityTypeRoute?.entityType;
|
|
8599
|
+
if (!entityType) throw new Error(`entity type middleware did not run`);
|
|
8600
|
+
if (await canAccessEntityType(ctx, entityType, `spawn`, request)) return void 0;
|
|
8601
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
|
|
8602
|
+
}
|
|
8603
|
+
async function withEntityTypeRegistrationPermission(request, ctx) {
|
|
8604
|
+
const parsed = normalizeEntityTypeRequest(routeBody(request));
|
|
8605
|
+
if (!parsed.name) return void 0;
|
|
8606
|
+
const existing = await ctx.entityManager.registry.getEntityType(parsed.name);
|
|
8607
|
+
if (existing) {
|
|
8608
|
+
request.entityTypeRoute = { entityType: existing };
|
|
8609
|
+
if (await canAccessEntityType(ctx, existing, `manage`, request)) return void 0;
|
|
8610
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${existing.name}`);
|
|
8611
|
+
}
|
|
8612
|
+
if (await canRegisterEntityType(ctx, parsed, request)) return void 0;
|
|
8613
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to register entity types`);
|
|
7704
8614
|
}
|
|
7705
8615
|
async function discoverServeEndpoint(ctx, parsed) {
|
|
7706
8616
|
try {
|
|
@@ -7709,17 +8619,17 @@ async function discoverServeEndpoint(ctx, parsed) {
|
|
|
7709
8619
|
const manifest = await response.json();
|
|
7710
8620
|
if (manifest.name !== parsed.name) return apiError(400, ErrCodeServeEndpointNameMismatch, `Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`);
|
|
7711
8621
|
manifest.serve_endpoint = parsed.serve_endpoint;
|
|
8622
|
+
manifest.permission_grants = parsed.permission_grants;
|
|
7712
8623
|
const entityType = await ctx.entityManager.registerEntityType(normalizeEntityTypeRequest(manifest));
|
|
8624
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, manifest);
|
|
7713
8625
|
return json(toPublicEntityType(entityType), { status: 201 });
|
|
7714
8626
|
} catch (err) {
|
|
7715
8627
|
if (err instanceof ElectricAgentsError) throw err;
|
|
7716
8628
|
return apiError(502, ErrCodeServeEndpointUnreachable, `Failed to reach serve endpoint: ${err instanceof Error ? err.message : String(err)}`);
|
|
7717
8629
|
}
|
|
7718
8630
|
}
|
|
7719
|
-
async function getEntityType(request
|
|
7720
|
-
|
|
7721
|
-
if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
|
|
7722
|
-
return json(toPublicEntityType(entityType));
|
|
8631
|
+
async function getEntityType(request) {
|
|
8632
|
+
return json(toPublicEntityType(request.entityTypeRoute.entityType));
|
|
7723
8633
|
}
|
|
7724
8634
|
async function amendSchemas(request, ctx) {
|
|
7725
8635
|
const parsed = routeBody(request);
|
|
@@ -7733,6 +8643,47 @@ async function deleteEntityType(request, ctx) {
|
|
|
7733
8643
|
await ctx.entityManager.deleteEntityType(request.params.name);
|
|
7734
8644
|
return status(204);
|
|
7735
8645
|
}
|
|
8646
|
+
async function listTypePermissionGrants(request, ctx) {
|
|
8647
|
+
const grants = await ctx.entityManager.registry.listEntityTypePermissionGrants(request.entityTypeRoute.entityType.name);
|
|
8648
|
+
return json({ grants });
|
|
8649
|
+
}
|
|
8650
|
+
async function createTypePermissionGrant(request, ctx) {
|
|
8651
|
+
const parsed = routeBody(request);
|
|
8652
|
+
const grant = await ctx.entityManager.registry.createEntityTypePermissionGrant({
|
|
8653
|
+
entityType: request.entityTypeRoute.entityType.name,
|
|
8654
|
+
permission: parsed.permission,
|
|
8655
|
+
subjectKind: parsed.subject_kind,
|
|
8656
|
+
subjectValue: parsed.subject_value,
|
|
8657
|
+
expiresAt: parseExpiresAt(parsed.expires_at),
|
|
8658
|
+
createdBy: ctx.principal.url
|
|
8659
|
+
});
|
|
8660
|
+
return json(grant, { status: 201 });
|
|
8661
|
+
}
|
|
8662
|
+
async function deleteTypePermissionGrant(request, ctx) {
|
|
8663
|
+
const deleted = await ctx.entityManager.registry.deleteEntityTypePermissionGrant(request.entityTypeRoute.entityType.name, parseGrantId(request));
|
|
8664
|
+
return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
|
|
8665
|
+
}
|
|
8666
|
+
async function applyRegistrationPermissionGrants(ctx, entityType, request) {
|
|
8667
|
+
for (const grant of request.permission_grants ?? []) await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
|
|
8668
|
+
entityType,
|
|
8669
|
+
permission: grant.permission,
|
|
8670
|
+
subjectKind: grant.subject_kind,
|
|
8671
|
+
subjectValue: grant.subject_value,
|
|
8672
|
+
expiresAt: parseExpiresAt(grant.expires_at),
|
|
8673
|
+
createdBy: ctx.principal.url
|
|
8674
|
+
});
|
|
8675
|
+
}
|
|
8676
|
+
function parseGrantId(request) {
|
|
8677
|
+
const grantId = Number.parseInt(String(request.params.grantId), 10);
|
|
8678
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
|
|
8679
|
+
return grantId;
|
|
8680
|
+
}
|
|
8681
|
+
function parseExpiresAt(value) {
|
|
8682
|
+
if (value === void 0) return void 0;
|
|
8683
|
+
const expiresAt = new Date(value);
|
|
8684
|
+
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
8685
|
+
return expiresAt;
|
|
8686
|
+
}
|
|
7736
8687
|
function normalizeEntityTypeRequest(parsed) {
|
|
7737
8688
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
7738
8689
|
return {
|
|
@@ -7745,7 +8696,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
7745
8696
|
default_dispatch_policy: parsed.default_dispatch_policy ?? (serveEndpoint ? { targets: [{
|
|
7746
8697
|
type: `webhook`,
|
|
7747
8698
|
url: serveEndpoint
|
|
7748
|
-
}] } : void 0)
|
|
8699
|
+
}] } : void 0),
|
|
8700
|
+
permission_grants: parsed.permission_grants
|
|
7749
8701
|
};
|
|
7750
8702
|
}
|
|
7751
8703
|
function toPublicEntityType(entityType) {
|
|
@@ -7804,6 +8756,7 @@ function applyCors(response) {
|
|
|
7804
8756
|
`content-type`,
|
|
7805
8757
|
`authorization`,
|
|
7806
8758
|
`electric-claim-token`,
|
|
8759
|
+
`electric-owner-entity`,
|
|
7807
8760
|
ELECTRIC_PRINCIPAL_HEADER,
|
|
7808
8761
|
`ngrok-skip-browser-warning`
|
|
7809
8762
|
].join(`, `));
|
|
@@ -7854,7 +8807,7 @@ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMemb
|
|
|
7854
8807
|
observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
|
|
7855
8808
|
async function ensureEntitiesMembershipStream(request, ctx) {
|
|
7856
8809
|
const parsed = routeBody(request);
|
|
7857
|
-
const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {});
|
|
8810
|
+
const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
|
|
7858
8811
|
return json(result);
|
|
7859
8812
|
}
|
|
7860
8813
|
async function ensureCronStream(request, ctx) {
|