@electric-ax/agents-server 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +1530 -256
- package/dist/index.cjs +1517 -232
- package/dist/index.d.cts +1359 -212
- package/dist/index.d.ts +1359 -212
- package/dist/index.js +1519 -234
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/0011_entity_permissions.sql +100 -0
- package/drizzle/0012_horton_user_manage_permission.sql +25 -0
- package/drizzle/0013_worker_user_manage_permission.sql +25 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/db/schema.ts +200 -0
- package/src/electric-agents-types.ts +147 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +411 -62
- package/src/entity-projector.ts +79 -17
- package/src/entity-registry.ts +681 -5
- package/src/index.ts +11 -0
- package/src/permissions.ts +239 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +125 -4
- package/src/routing/electric-proxy-router.ts +4 -0
- package/src/routing/entities-router.ts +362 -20
- package/src/routing/entity-types-router.ts +244 -15
- package/src/routing/hooks.ts +1 -0
- package/src/routing/observations-router.ts +2 -1
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/runtime.ts +34 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/server-utils.ts +192 -12
- package/src/wake-registry.ts +30 -11
package/dist/entrypoint.js
CHANGED
|
@@ -4,13 +4,13 @@ import { DurableStreamTestServer } from "@durable-streams/server";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createServerAdapter } from "@whatwg-node/server";
|
|
6
6
|
import { Agent } from "undici";
|
|
7
|
-
import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
7
|
+
import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
8
8
|
import fs, { existsSync } from "node:fs";
|
|
9
9
|
import path, { dirname, resolve } from "node:path";
|
|
10
10
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
11
11
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
12
12
|
import postgres from "postgres";
|
|
13
|
-
import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
|
|
13
|
+
import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
14
14
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
15
15
|
import { AutoRouter, Router, json, status, withParams } from "itty-router";
|
|
16
16
|
import { access, readFile } from "node:fs/promises";
|
|
@@ -41,11 +41,16 @@ __export(schema_exports, {
|
|
|
41
41
|
entities: () => entities,
|
|
42
42
|
entityBridges: () => entityBridges,
|
|
43
43
|
entityDispatchState: () => entityDispatchState,
|
|
44
|
+
entityEffectivePermissions: () => entityEffectivePermissions,
|
|
45
|
+
entityLineage: () => entityLineage,
|
|
44
46
|
entityManifestSources: () => entityManifestSources,
|
|
47
|
+
entityPermissionGrants: () => entityPermissionGrants,
|
|
48
|
+
entityTypePermissionGrants: () => entityTypePermissionGrants,
|
|
45
49
|
entityTypes: () => entityTypes,
|
|
46
50
|
runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
|
|
47
51
|
runners: () => runners,
|
|
48
52
|
scheduledTasks: () => scheduledTasks,
|
|
53
|
+
sharedStateLinks: () => sharedStateLinks,
|
|
49
54
|
subscriptionWebhooks: () => subscriptionWebhooks,
|
|
50
55
|
tagStreamOutbox: () => tagStreamOutbox,
|
|
51
56
|
users: () => users,
|
|
@@ -76,6 +81,7 @@ const entities = pgTable(`entities`, {
|
|
|
76
81
|
tags: jsonb(`tags`).notNull().default({}),
|
|
77
82
|
tagsIndex: text(`tags_index`).array().notNull().default(sql`'{}'::text[]`),
|
|
78
83
|
spawnArgs: jsonb(`spawn_args`).default({}),
|
|
84
|
+
sandbox: jsonb(`sandbox`),
|
|
79
85
|
parent: text(`parent`),
|
|
80
86
|
createdBy: text(`created_by`),
|
|
81
87
|
typeRevision: integer(`type_revision`),
|
|
@@ -92,6 +98,94 @@ const entities = pgTable(`entities`, {
|
|
|
92
98
|
index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
93
99
|
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
|
|
94
100
|
]);
|
|
101
|
+
const entityTypePermissionGrants = pgTable(`entity_type_permission_grants`, {
|
|
102
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
103
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
104
|
+
entityType: text(`entity_type`).notNull(),
|
|
105
|
+
permission: text(`permission`).notNull(),
|
|
106
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
107
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
108
|
+
createdBy: text(`created_by`),
|
|
109
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
110
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
111
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
112
|
+
}, (table) => [
|
|
113
|
+
index(`idx_type_permission_grants_lookup`).on(table.tenantId, table.entityType, table.permission, table.subjectKind, table.subjectValue),
|
|
114
|
+
index(`idx_type_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
|
|
115
|
+
check(`chk_type_permission_grants_permission`, sql`${table.permission} IN ('spawn', 'manage')`),
|
|
116
|
+
check(`chk_type_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
|
|
117
|
+
]);
|
|
118
|
+
const entityLineage = pgTable(`entity_lineage`, {
|
|
119
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
120
|
+
ancestorUrl: text(`ancestor_url`).notNull(),
|
|
121
|
+
descendantUrl: text(`descendant_url`).notNull(),
|
|
122
|
+
depth: integer(`depth`).notNull(),
|
|
123
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
|
|
124
|
+
}, (table) => [
|
|
125
|
+
primaryKey({ columns: [
|
|
126
|
+
table.tenantId,
|
|
127
|
+
table.ancestorUrl,
|
|
128
|
+
table.descendantUrl
|
|
129
|
+
] }),
|
|
130
|
+
index(`idx_entity_lineage_descendant`).on(table.tenantId, table.descendantUrl),
|
|
131
|
+
check(`chk_entity_lineage_depth`, sql`${table.depth} >= 0`)
|
|
132
|
+
]);
|
|
133
|
+
const entityPermissionGrants = pgTable(`entity_permission_grants`, {
|
|
134
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
135
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
136
|
+
entityUrl: text(`entity_url`).notNull(),
|
|
137
|
+
permission: text(`permission`).notNull(),
|
|
138
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
139
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
140
|
+
propagation: text(`propagation`).notNull().default(`self`),
|
|
141
|
+
copyToChildren: boolean(`copy_to_children`).notNull().default(false),
|
|
142
|
+
createdBy: text(`created_by`),
|
|
143
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
144
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
145
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
146
|
+
}, (table) => [
|
|
147
|
+
index(`idx_entity_permission_grants_entity`).on(table.tenantId, table.entityUrl),
|
|
148
|
+
index(`idx_entity_permission_grants_subject`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue),
|
|
149
|
+
index(`idx_entity_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
|
|
150
|
+
check(`chk_entity_permission_grants_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
|
|
151
|
+
check(`chk_entity_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`),
|
|
152
|
+
check(`chk_entity_permission_grants_propagation`, sql`${table.propagation} IN ('self', 'descendants')`)
|
|
153
|
+
]);
|
|
154
|
+
const entityEffectivePermissions = pgTable(`entity_effective_permissions`, {
|
|
155
|
+
id: bigserial(`id`, { mode: `number` }).primaryKey(),
|
|
156
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
157
|
+
entityUrl: text(`entity_url`).notNull(),
|
|
158
|
+
sourceEntityUrl: text(`source_entity_url`).notNull(),
|
|
159
|
+
sourceGrantId: bigint(`source_grant_id`, { mode: `number` }).notNull(),
|
|
160
|
+
permission: text(`permission`).notNull(),
|
|
161
|
+
subjectKind: text(`subject_kind`).notNull(),
|
|
162
|
+
subjectValue: text(`subject_value`).notNull(),
|
|
163
|
+
expiresAt: timestamp(`expires_at`, { withTimezone: true }),
|
|
164
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
|
|
165
|
+
}, (table) => [
|
|
166
|
+
unique(`uq_entity_effective_permission`).on(table.tenantId, table.entityUrl, table.sourceGrantId),
|
|
167
|
+
index(`idx_entity_effective_permissions_lookup`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue, table.entityUrl),
|
|
168
|
+
index(`idx_entity_effective_permissions_entity`).on(table.tenantId, table.entityUrl),
|
|
169
|
+
index(`idx_entity_effective_permissions_expiry`).on(table.tenantId, table.expiresAt),
|
|
170
|
+
check(`chk_entity_effective_permissions_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
|
|
171
|
+
check(`chk_entity_effective_permissions_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
|
|
172
|
+
]);
|
|
173
|
+
const sharedStateLinks = pgTable(`shared_state_links`, {
|
|
174
|
+
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
175
|
+
sharedStateId: text(`shared_state_id`).notNull(),
|
|
176
|
+
ownerEntityUrl: text(`owner_entity_url`).notNull(),
|
|
177
|
+
manifestKey: text(`manifest_key`).notNull(),
|
|
178
|
+
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
179
|
+
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
180
|
+
}, (table) => [
|
|
181
|
+
primaryKey({ columns: [
|
|
182
|
+
table.tenantId,
|
|
183
|
+
table.ownerEntityUrl,
|
|
184
|
+
table.manifestKey
|
|
185
|
+
] }),
|
|
186
|
+
index(`idx_shared_state_links_shared_state`).on(table.tenantId, table.sharedStateId),
|
|
187
|
+
index(`idx_shared_state_links_owner`).on(table.tenantId, table.ownerEntityUrl)
|
|
188
|
+
]);
|
|
95
189
|
const users = pgTable(`users`, {
|
|
96
190
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
97
191
|
id: text(`id`).notNull(),
|
|
@@ -117,6 +211,7 @@ const runners = pgTable(`runners`, {
|
|
|
117
211
|
kind: text(`kind`).notNull().default(`local`),
|
|
118
212
|
adminStatus: text(`admin_status`).notNull().default(`enabled`),
|
|
119
213
|
wakeStream: text(`wake_stream`).notNull(),
|
|
214
|
+
sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
|
|
120
215
|
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
121
216
|
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
122
217
|
}, (table) => [
|
|
@@ -277,12 +372,18 @@ const entityBridges = pgTable(`entity_bridges`, {
|
|
|
277
372
|
sourceRef: text(`source_ref`).notNull(),
|
|
278
373
|
tags: jsonb(`tags`).notNull(),
|
|
279
374
|
streamUrl: text(`stream_url`).notNull(),
|
|
375
|
+
principalUrl: text(`principal_url`),
|
|
376
|
+
principalKind: text(`principal_kind`),
|
|
280
377
|
shapeHandle: text(`shape_handle`),
|
|
281
378
|
shapeOffset: text(`shape_offset`),
|
|
282
379
|
lastObserverActivityAt: timestamp(`last_observer_activity_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
283
380
|
createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
|
|
284
381
|
updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
|
|
285
|
-
}, (table) => [
|
|
382
|
+
}, (table) => [
|
|
383
|
+
primaryKey({ columns: [table.tenantId, table.sourceRef] }),
|
|
384
|
+
unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl),
|
|
385
|
+
index(`idx_entity_bridges_principal`).on(table.tenantId, table.principalKind, table.principalUrl)
|
|
386
|
+
]);
|
|
286
387
|
const entityManifestSources = pgTable(`entity_manifest_sources`, {
|
|
287
388
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
288
389
|
ownerEntityUrl: text(`owner_entity_url`).notNull(),
|
|
@@ -422,6 +523,7 @@ function toPublicEntity(entity) {
|
|
|
422
523
|
dispatch_policy: entity.dispatch_policy,
|
|
423
524
|
tags: entity.tags,
|
|
424
525
|
spawn_args: entity.spawn_args,
|
|
526
|
+
sandbox: entity.sandbox,
|
|
425
527
|
parent: entity.parent,
|
|
426
528
|
created_by: entity.created_by,
|
|
427
529
|
created_at: entity.created_at,
|
|
@@ -639,7 +741,7 @@ var StreamClient = class {
|
|
|
639
741
|
});
|
|
640
742
|
});
|
|
641
743
|
}
|
|
642
|
-
async fork(path$1, sourcePath) {
|
|
744
|
+
async fork(path$1, sourcePath, opts) {
|
|
643
745
|
return await withSpan(`stream.fork`, async (span) => {
|
|
644
746
|
span.setAttributes({
|
|
645
747
|
[ATTR.STREAM_PATH]: path$1,
|
|
@@ -649,6 +751,11 @@ var StreamClient = class {
|
|
|
649
751
|
"content-type": `application/json`,
|
|
650
752
|
"Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
|
|
651
753
|
};
|
|
754
|
+
if (opts?.forkPointer) {
|
|
755
|
+
const ZERO_OFFSET = `0000000000000000_0000000000000000`;
|
|
756
|
+
headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
|
|
757
|
+
if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
|
|
758
|
+
}
|
|
652
759
|
injectTraceHeaders(headers);
|
|
653
760
|
const response = await fetch(this.streamUrl(path$1), {
|
|
654
761
|
method: `PUT`,
|
|
@@ -1037,29 +1144,136 @@ function buildElectricProxyTarget(options) {
|
|
|
1037
1144
|
if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
|
|
1038
1145
|
const table = options.incomingUrl.searchParams.get(`table`);
|
|
1039
1146
|
if (table === `entities`) {
|
|
1040
|
-
target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
|
|
1041
|
-
|
|
1147
|
+
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"`);
|
|
1148
|
+
applyShapeWhere(target, buildReadableEntitiesWhere({
|
|
1149
|
+
tenantId: options.tenantId,
|
|
1150
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1151
|
+
principalKind: options.principalKind ?? ``,
|
|
1152
|
+
permissionBypass: options.permissionBypass
|
|
1153
|
+
}));
|
|
1042
1154
|
} else if (table === `entity_types`) {
|
|
1043
1155
|
target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
|
|
1044
|
-
|
|
1156
|
+
applyShapeWhere(target, buildSpawnableEntityTypesWhere({
|
|
1157
|
+
tenantId: options.tenantId,
|
|
1158
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1159
|
+
principalKind: options.principalKind ?? ``,
|
|
1160
|
+
permissionBypass: options.permissionBypass
|
|
1161
|
+
}));
|
|
1045
1162
|
} else if (table === `runners`) {
|
|
1046
|
-
target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
|
|
1163
|
+
target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
|
|
1047
1164
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral$2(options.principalUrl ?? ``)}`]);
|
|
1165
|
+
} else if (table === `users`) {
|
|
1166
|
+
target.searchParams.set(`columns`, `"tenant_id","id","display_name","email","avatar_url","created_at","updated_at"`);
|
|
1167
|
+
applyTenantShapeWhere(target, options.tenantId);
|
|
1168
|
+
} else if (table === `entity_effective_permissions`) {
|
|
1169
|
+
target.searchParams.set(`columns`, `"tenant_id","id","entity_url","source_entity_url","source_grant_id","permission","subject_kind","subject_value","expires_at","created_at"`);
|
|
1170
|
+
applyShapeWhere(target, buildCurrentPrincipalEntityEffectivePermissionsWhere({
|
|
1171
|
+
tenantId: options.tenantId,
|
|
1172
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1173
|
+
principalKind: options.principalKind ?? ``,
|
|
1174
|
+
permissionBypass: options.permissionBypass
|
|
1175
|
+
}));
|
|
1048
1176
|
} else if (table === `runner_runtime_diagnostics`) {
|
|
1049
1177
|
target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
|
|
1050
1178
|
applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral$2(options.principalUrl ?? ``)}`]);
|
|
1051
1179
|
} else if (table === `entity_dispatch_state`) {
|
|
1052
1180
|
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"`);
|
|
1053
|
-
|
|
1181
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
1182
|
+
tenantId: options.tenantId,
|
|
1183
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1184
|
+
principalKind: options.principalKind ?? ``,
|
|
1185
|
+
permissionBypass: options.permissionBypass
|
|
1186
|
+
}));
|
|
1054
1187
|
} else if (table === `wake_notifications`) {
|
|
1055
1188
|
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"`);
|
|
1056
|
-
|
|
1189
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
1190
|
+
tenantId: options.tenantId,
|
|
1191
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1192
|
+
principalKind: options.principalKind ?? ``,
|
|
1193
|
+
permissionBypass: options.permissionBypass
|
|
1194
|
+
}));
|
|
1057
1195
|
} else if (table === `consumer_claims`) {
|
|
1058
1196
|
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"`);
|
|
1059
|
-
|
|
1197
|
+
applyShapeWhere(target, buildReadableEntityUrlWhere({
|
|
1198
|
+
tenantId: options.tenantId,
|
|
1199
|
+
principalUrl: options.principalUrl ?? ``,
|
|
1200
|
+
principalKind: options.principalKind ?? ``,
|
|
1201
|
+
permissionBypass: options.permissionBypass
|
|
1202
|
+
}));
|
|
1060
1203
|
}
|
|
1061
1204
|
return target;
|
|
1062
1205
|
}
|
|
1206
|
+
function buildReadableEntitiesWhere(options) {
|
|
1207
|
+
const tenant = sqlStringLiteral$2(options.tenantId);
|
|
1208
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
1209
|
+
const principalUrl$1 = sqlStringLiteral$2(options.principalUrl);
|
|
1210
|
+
const principalKind = sqlStringLiteral$2(options.principalKind);
|
|
1211
|
+
return [
|
|
1212
|
+
`tenant_id = ${tenant}`,
|
|
1213
|
+
`AND (`,
|
|
1214
|
+
` created_by = ${principalUrl$1}`,
|
|
1215
|
+
` OR url IN (`,
|
|
1216
|
+
` SELECT entity_url`,
|
|
1217
|
+
` FROM entity_effective_permissions`,
|
|
1218
|
+
` WHERE tenant_id = ${tenant}`,
|
|
1219
|
+
` AND permission IN ('read', 'manage')`,
|
|
1220
|
+
` AND (`,
|
|
1221
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
1222
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
1223
|
+
` )`,
|
|
1224
|
+
` )`,
|
|
1225
|
+
`)`
|
|
1226
|
+
].join(`\n`);
|
|
1227
|
+
}
|
|
1228
|
+
function buildReadableEntityUrlWhere(options) {
|
|
1229
|
+
const tenant = sqlStringLiteral$2(options.tenantId);
|
|
1230
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
1231
|
+
return [
|
|
1232
|
+
`tenant_id = ${tenant}`,
|
|
1233
|
+
`AND entity_url IN (`,
|
|
1234
|
+
` SELECT url`,
|
|
1235
|
+
` FROM entities`,
|
|
1236
|
+
` WHERE ${indentWhere(buildReadableEntitiesWhere(options), ` `).trimStart()}`,
|
|
1237
|
+
`)`
|
|
1238
|
+
].join(`\n`);
|
|
1239
|
+
}
|
|
1240
|
+
function buildCurrentPrincipalEntityEffectivePermissionsWhere(options) {
|
|
1241
|
+
const tenant = sqlStringLiteral$2(options.tenantId);
|
|
1242
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
1243
|
+
const principalUrl$1 = sqlStringLiteral$2(options.principalUrl);
|
|
1244
|
+
const principalKind = sqlStringLiteral$2(options.principalKind);
|
|
1245
|
+
return [
|
|
1246
|
+
`tenant_id = ${tenant}`,
|
|
1247
|
+
`AND (`,
|
|
1248
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
1249
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
1250
|
+
`)`,
|
|
1251
|
+
`AND entity_url IN (`,
|
|
1252
|
+
` SELECT url`,
|
|
1253
|
+
` FROM entities`,
|
|
1254
|
+
` WHERE ${buildReadableEntitiesWhere(options)}`,
|
|
1255
|
+
`)`
|
|
1256
|
+
].join(`\n`);
|
|
1257
|
+
}
|
|
1258
|
+
function buildSpawnableEntityTypesWhere(options) {
|
|
1259
|
+
const tenant = sqlStringLiteral$2(options.tenantId);
|
|
1260
|
+
if (options.permissionBypass) return `tenant_id = ${tenant}`;
|
|
1261
|
+
const principalUrl$1 = sqlStringLiteral$2(options.principalUrl);
|
|
1262
|
+
const principalKind = sqlStringLiteral$2(options.principalKind);
|
|
1263
|
+
return [
|
|
1264
|
+
`tenant_id = ${tenant}`,
|
|
1265
|
+
`AND name IN (`,
|
|
1266
|
+
` SELECT entity_type`,
|
|
1267
|
+
` FROM entity_type_permission_grants`,
|
|
1268
|
+
` WHERE tenant_id = ${tenant}`,
|
|
1269
|
+
` AND permission IN ('spawn', 'manage')`,
|
|
1270
|
+
` AND (`,
|
|
1271
|
+
` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
|
|
1272
|
+
` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
|
|
1273
|
+
` )`,
|
|
1274
|
+
`)`
|
|
1275
|
+
].join(`\n`);
|
|
1276
|
+
}
|
|
1063
1277
|
async function forwardFetchRequest(options) {
|
|
1064
1278
|
const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting, options.durableStreamsUrl);
|
|
1065
1279
|
const routingInput = {
|
|
@@ -1094,13 +1308,18 @@ function decodeJsonObject(body) {
|
|
|
1094
1308
|
return null;
|
|
1095
1309
|
}
|
|
1096
1310
|
function applyTenantShapeWhere(target, tenantId, extraConditions = []) {
|
|
1097
|
-
|
|
1311
|
+
applyShapeWhere(target, [`tenant_id = ${sqlStringLiteral$2(tenantId)}`, ...extraConditions].join(` AND `));
|
|
1312
|
+
}
|
|
1313
|
+
function applyShapeWhere(target, enforcedWhere) {
|
|
1098
1314
|
const existingWhere = target.searchParams.get(`where`);
|
|
1099
|
-
target.searchParams.set(`where`, existingWhere ? `${
|
|
1315
|
+
target.searchParams.set(`where`, existingWhere ? `${enforcedWhere} AND (${existingWhere})` : enforcedWhere);
|
|
1100
1316
|
}
|
|
1101
1317
|
function sqlStringLiteral$2(value) {
|
|
1102
1318
|
return `'${value.replace(/'/g, `''`)}'`;
|
|
1103
1319
|
}
|
|
1320
|
+
function indentWhere(where, prefix) {
|
|
1321
|
+
return where.split(`\n`).map((line) => `${prefix}${line}`).join(`\n`);
|
|
1322
|
+
}
|
|
1104
1323
|
|
|
1105
1324
|
//#endregion
|
|
1106
1325
|
//#region src/routing/agent-ui-router.ts
|
|
@@ -1394,6 +1613,262 @@ function isLoopbackHostname(hostname) {
|
|
|
1394
1613
|
return hostname === `localhost` || hostname === `127.0.0.1` || hostname === `::1`;
|
|
1395
1614
|
}
|
|
1396
1615
|
|
|
1616
|
+
//#endregion
|
|
1617
|
+
//#region src/principal.ts
|
|
1618
|
+
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
1619
|
+
const PRINCIPAL_KINDS = new Set([
|
|
1620
|
+
`user`,
|
|
1621
|
+
`agent`,
|
|
1622
|
+
`service`,
|
|
1623
|
+
`system`
|
|
1624
|
+
]);
|
|
1625
|
+
function parsePrincipalKey(input) {
|
|
1626
|
+
const colon = input.indexOf(`:`);
|
|
1627
|
+
if (colon <= 0) throw new Error(`Invalid principal identifier`);
|
|
1628
|
+
const kind = input.slice(0, colon);
|
|
1629
|
+
const id = input.slice(colon + 1);
|
|
1630
|
+
if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
|
|
1631
|
+
if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
|
|
1632
|
+
const key = `${kind}:${id}`;
|
|
1633
|
+
return {
|
|
1634
|
+
kind,
|
|
1635
|
+
id,
|
|
1636
|
+
key,
|
|
1637
|
+
url: `/principal/${encodeURIComponent(key)}`
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
function principalUrl(key) {
|
|
1641
|
+
return parsePrincipalKey(key).url;
|
|
1642
|
+
}
|
|
1643
|
+
function parsePrincipalUrl(url) {
|
|
1644
|
+
if (!url.startsWith(`/principal/`)) return null;
|
|
1645
|
+
const segment = url.slice(`/principal/`.length);
|
|
1646
|
+
if (!segment || segment.includes(`/`)) return null;
|
|
1647
|
+
try {
|
|
1648
|
+
return parsePrincipalKey(decodeURIComponent(segment));
|
|
1649
|
+
} catch {
|
|
1650
|
+
return null;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
function parsePrincipalInput(input) {
|
|
1654
|
+
const urlPrincipal = parsePrincipalUrl(input);
|
|
1655
|
+
if (urlPrincipal) return urlPrincipal;
|
|
1656
|
+
try {
|
|
1657
|
+
return parsePrincipalKey(input);
|
|
1658
|
+
} catch {
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
function getPrincipalFromRequest(request) {
|
|
1663
|
+
const value = request.headers.get(ELECTRIC_PRINCIPAL_HEADER);
|
|
1664
|
+
if (!value) return null;
|
|
1665
|
+
return parsePrincipalInput(value);
|
|
1666
|
+
}
|
|
1667
|
+
function getDevPrincipal() {
|
|
1668
|
+
return parsePrincipalKey(`system:dev-local`);
|
|
1669
|
+
}
|
|
1670
|
+
const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
|
|
1671
|
+
`framework`,
|
|
1672
|
+
`auth-sync`,
|
|
1673
|
+
`dev-local`
|
|
1674
|
+
]);
|
|
1675
|
+
function isBuiltInSystemPrincipalUrl(url) {
|
|
1676
|
+
if (!url?.startsWith(`/principal/`)) return false;
|
|
1677
|
+
try {
|
|
1678
|
+
const principal = parsePrincipalUrl(url);
|
|
1679
|
+
if (!principal) return false;
|
|
1680
|
+
return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
|
|
1681
|
+
} catch {
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function principalFromCreatedBy(createdBy) {
|
|
1686
|
+
if (!createdBy) return void 0;
|
|
1687
|
+
const principal = parsePrincipalUrl(createdBy);
|
|
1688
|
+
if (!principal) return {
|
|
1689
|
+
url: createdBy,
|
|
1690
|
+
key: null
|
|
1691
|
+
};
|
|
1692
|
+
return {
|
|
1693
|
+
url: principal.url,
|
|
1694
|
+
key: principal.key,
|
|
1695
|
+
kind: principal.kind,
|
|
1696
|
+
id: principal.id
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
const principalIdentityStateSchema = Type.Object({
|
|
1700
|
+
kind: Type.Union([
|
|
1701
|
+
Type.Literal(`user`),
|
|
1702
|
+
Type.Literal(`agent`),
|
|
1703
|
+
Type.Literal(`service`),
|
|
1704
|
+
Type.Literal(`system`)
|
|
1705
|
+
]),
|
|
1706
|
+
id: Type.String(),
|
|
1707
|
+
key: Type.String(),
|
|
1708
|
+
url: Type.String(),
|
|
1709
|
+
updated_at: Type.String(),
|
|
1710
|
+
display_name: Type.Optional(Type.String()),
|
|
1711
|
+
email: Type.Optional(Type.String()),
|
|
1712
|
+
avatar_url: Type.Optional(Type.String()),
|
|
1713
|
+
auth_provider: Type.Optional(Type.String()),
|
|
1714
|
+
auth_subject: Type.Optional(Type.String()),
|
|
1715
|
+
claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
1716
|
+
created_at: Type.Optional(Type.String())
|
|
1717
|
+
}, { additionalProperties: false });
|
|
1718
|
+
const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
|
|
1719
|
+
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/permissions.ts
|
|
1722
|
+
const authzDecisionCache = new WeakMap();
|
|
1723
|
+
function principalSubject(principal) {
|
|
1724
|
+
return {
|
|
1725
|
+
principalUrl: principal.url,
|
|
1726
|
+
principalKind: principal.kind
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
function isPermissionBypassPrincipal(ctx) {
|
|
1730
|
+
return isBuiltInSystemPrincipalUrl(ctx.principal.url);
|
|
1731
|
+
}
|
|
1732
|
+
async function canAccessEntity(ctx, entity, permission, request) {
|
|
1733
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
1734
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
1735
|
+
const builtInAllowed = entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal));
|
|
1736
|
+
return await applyAuthorizationHook(ctx, {
|
|
1737
|
+
verb: permission,
|
|
1738
|
+
resourceKey: `entity:${entity.url}`,
|
|
1739
|
+
resource: {
|
|
1740
|
+
kind: `entity`,
|
|
1741
|
+
entity
|
|
1742
|
+
},
|
|
1743
|
+
builtInAllowed,
|
|
1744
|
+
request
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
async function canAccessEntityType(ctx, entityType, permission, request) {
|
|
1748
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
1749
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
1750
|
+
const builtInAllowed = await ctx.entityManager.registry.hasEntityTypePermission(entityType.name, permission, principalSubject(ctx.principal));
|
|
1751
|
+
return await applyAuthorizationHook(ctx, {
|
|
1752
|
+
verb: permission,
|
|
1753
|
+
resourceKey: `entity_type:${entityType.name}`,
|
|
1754
|
+
resource: {
|
|
1755
|
+
kind: `entity_type`,
|
|
1756
|
+
entityType
|
|
1757
|
+
},
|
|
1758
|
+
builtInAllowed,
|
|
1759
|
+
request
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
async function canRegisterEntityType(ctx, input, request) {
|
|
1763
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
1764
|
+
return await applyAuthorizationHook(ctx, {
|
|
1765
|
+
verb: `manage`,
|
|
1766
|
+
resourceKey: `entity_type_registration:${input.name}`,
|
|
1767
|
+
resource: {
|
|
1768
|
+
kind: `entity_type_registration`,
|
|
1769
|
+
entityTypeName: input.name
|
|
1770
|
+
},
|
|
1771
|
+
builtInAllowed: true,
|
|
1772
|
+
request
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
async function canAccessSharedState(ctx, sharedStateId, permission, request, ownerEntityUrl) {
|
|
1776
|
+
if (isPermissionBypassPrincipal(ctx)) return true;
|
|
1777
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
1778
|
+
const storedLinkedEntityUrls = await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(sharedStateId);
|
|
1779
|
+
const bootstrapEntityUrls = storedLinkedEntityUrls.length === 0 && ownerEntityUrl ? [ownerEntityUrl] : [];
|
|
1780
|
+
const linkedEntityUrls = [...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls])];
|
|
1781
|
+
for (const entityUrl of linkedEntityUrls) {
|
|
1782
|
+
const entity = await ctx.entityManager.registry.getEntity(entityUrl);
|
|
1783
|
+
if (!entity) continue;
|
|
1784
|
+
if (entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal))) return await applyAuthorizationHook(ctx, {
|
|
1785
|
+
verb: permission,
|
|
1786
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
1787
|
+
resource: {
|
|
1788
|
+
kind: `shared_state`,
|
|
1789
|
+
sharedStateId,
|
|
1790
|
+
linkedEntityUrls
|
|
1791
|
+
},
|
|
1792
|
+
builtInAllowed: true,
|
|
1793
|
+
request
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
return await applyAuthorizationHook(ctx, {
|
|
1797
|
+
verb: permission,
|
|
1798
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
1799
|
+
resource: {
|
|
1800
|
+
kind: `shared_state`,
|
|
1801
|
+
sharedStateId,
|
|
1802
|
+
linkedEntityUrls
|
|
1803
|
+
},
|
|
1804
|
+
builtInAllowed: false,
|
|
1805
|
+
request
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
async function applyAuthorizationHook(ctx, input) {
|
|
1809
|
+
const hook = ctx.authorizeRequest;
|
|
1810
|
+
if (!hook) return input.builtInAllowed;
|
|
1811
|
+
const cacheKey = [
|
|
1812
|
+
ctx.service,
|
|
1813
|
+
ctx.principal.url,
|
|
1814
|
+
input.verb,
|
|
1815
|
+
input.resourceKey
|
|
1816
|
+
].join(`|`);
|
|
1817
|
+
const cached = getCachedDecision(hook, cacheKey);
|
|
1818
|
+
if (cached) return cached.decision === `allow`;
|
|
1819
|
+
let decision;
|
|
1820
|
+
try {
|
|
1821
|
+
decision = await hook({
|
|
1822
|
+
tenant: ctx.service,
|
|
1823
|
+
principal: ctx.principal,
|
|
1824
|
+
verb: input.verb,
|
|
1825
|
+
resource: input.resource,
|
|
1826
|
+
request: input.request ? requestMetadata(input.request) : void 0,
|
|
1827
|
+
builtInAllowed: input.builtInAllowed
|
|
1828
|
+
});
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
serverLog.warn(`[agent-server] authorization hook failed:`, error);
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
cacheDecision(hook, cacheKey, decision);
|
|
1834
|
+
return decision.decision === `allow`;
|
|
1835
|
+
}
|
|
1836
|
+
function getCachedDecision(hook, cacheKey) {
|
|
1837
|
+
const cache = authzDecisionCache.get(hook);
|
|
1838
|
+
const entry = cache?.get(cacheKey);
|
|
1839
|
+
if (!entry) return null;
|
|
1840
|
+
if (entry.expiresAt <= Date.now()) {
|
|
1841
|
+
cache?.delete(cacheKey);
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
return { decision: entry.decision };
|
|
1845
|
+
}
|
|
1846
|
+
function cacheDecision(hook, cacheKey, decision) {
|
|
1847
|
+
if (!decision.expires_at) return;
|
|
1848
|
+
const expiresAt = Date.parse(decision.expires_at);
|
|
1849
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
|
1850
|
+
let cache = authzDecisionCache.get(hook);
|
|
1851
|
+
if (!cache) {
|
|
1852
|
+
cache = new Map();
|
|
1853
|
+
authzDecisionCache.set(hook, cache);
|
|
1854
|
+
}
|
|
1855
|
+
cache.set(cacheKey, {
|
|
1856
|
+
decision: decision.decision,
|
|
1857
|
+
expiresAt
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
function requestMetadata(request) {
|
|
1861
|
+
const headers = {};
|
|
1862
|
+
request.headers.forEach((value, key) => {
|
|
1863
|
+
headers[key] = value;
|
|
1864
|
+
});
|
|
1865
|
+
return {
|
|
1866
|
+
method: request.method,
|
|
1867
|
+
url: request.url,
|
|
1868
|
+
headers
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1397
1872
|
//#endregion
|
|
1398
1873
|
//#region src/webhook-signing.ts
|
|
1399
1874
|
const encoder = new TextEncoder();
|
|
@@ -1484,6 +1959,7 @@ const subscriptionControlActions = [
|
|
|
1484
1959
|
`ack`,
|
|
1485
1960
|
`release`
|
|
1486
1961
|
];
|
|
1962
|
+
const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`;
|
|
1487
1963
|
const durableStreamsRouter = Router();
|
|
1488
1964
|
durableStreamsRouter.put(`/__ds/subscriptions/:subscriptionId`, putSubscriptionBase);
|
|
1489
1965
|
durableStreamsRouter.get(`/__ds/subscriptions/:subscriptionId`, getSubscriptionBase);
|
|
@@ -1701,6 +2177,8 @@ async function webhookJwks(_request, ctx) {
|
|
|
1701
2177
|
});
|
|
1702
2178
|
}
|
|
1703
2179
|
async function streamAppend(request, ctx) {
|
|
2180
|
+
const auth = await authorizeDurableStreamAccess(request, ctx);
|
|
2181
|
+
if (auth) return auth;
|
|
1704
2182
|
return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
|
|
1705
2183
|
request: {
|
|
1706
2184
|
method: req.method,
|
|
@@ -1717,8 +2195,9 @@ async function streamAppend(request, ctx) {
|
|
|
1717
2195
|
}));
|
|
1718
2196
|
}
|
|
1719
2197
|
async function proxyPassThrough(request, ctx) {
|
|
2198
|
+
const auth = await authorizeDurableStreamAccess(request, ctx);
|
|
2199
|
+
if (auth) return auth;
|
|
1720
2200
|
const streamPath = new URL(request.url).pathname;
|
|
1721
|
-
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
|
|
1722
2201
|
const upstream = await forwardToDurableStreams(ctx, request);
|
|
1723
2202
|
const method = request.method.toUpperCase();
|
|
1724
2203
|
const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
|
|
@@ -1729,6 +2208,51 @@ async function proxyPassThrough(request, ctx) {
|
|
|
1729
2208
|
await endTrackedRead?.();
|
|
1730
2209
|
}
|
|
1731
2210
|
}
|
|
2211
|
+
async function authorizeDurableStreamAccess(request, ctx) {
|
|
2212
|
+
const method = request.method.toUpperCase();
|
|
2213
|
+
const streamPath = new URL(request.url).pathname;
|
|
2214
|
+
if (method === `GET` || method === `HEAD`) {
|
|
2215
|
+
const registry = ctx.entityManager?.registry;
|
|
2216
|
+
const entity = registry?.getEntityByStream ? await registry.getEntityByStream(streamPath) : null;
|
|
2217
|
+
if (entity) {
|
|
2218
|
+
if (await canAccessEntity(ctx, entity, `read`, request)) return void 0;
|
|
2219
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${entity.url}`);
|
|
2220
|
+
}
|
|
2221
|
+
const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath);
|
|
2222
|
+
if (attachmentEntityUrl) {
|
|
2223
|
+
const attachmentEntity = registry?.getEntity ? await registry.getEntity(attachmentEntityUrl) : null;
|
|
2224
|
+
if (!attachmentEntity) return apiError(404, ErrCodeNotFound, `Entity not found`);
|
|
2225
|
+
if (await canAccessEntity(ctx, attachmentEntity, `read`, request)) return void 0;
|
|
2226
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${attachmentEntity.url}`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
const sharedStateId = sharedStateIdFromPath(streamPath);
|
|
2230
|
+
if (!sharedStateId) return void 0;
|
|
2231
|
+
if (method === `GET` || method === `HEAD`) {
|
|
2232
|
+
if (await canAccessSharedState(ctx, sharedStateId, `read`, request)) return void 0;
|
|
2233
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read shared state`);
|
|
2234
|
+
}
|
|
2235
|
+
if (method === `PUT` || method === `POST`) {
|
|
2236
|
+
const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
|
|
2237
|
+
if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
|
|
2238
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
|
|
2239
|
+
}
|
|
2240
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
|
|
2241
|
+
}
|
|
2242
|
+
function entityUrlFromAttachmentStreamPath(path$1) {
|
|
2243
|
+
const match = path$1.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/);
|
|
2244
|
+
if (!match) return null;
|
|
2245
|
+
return `/${match[1]}/${match[2]}`;
|
|
2246
|
+
}
|
|
2247
|
+
function sharedStateIdFromPath(path$1) {
|
|
2248
|
+
const match = path$1.match(/^\/_electric\/shared-state\/([^/]+)$/);
|
|
2249
|
+
if (!match) return null;
|
|
2250
|
+
try {
|
|
2251
|
+
return decodeURIComponent(match[1]);
|
|
2252
|
+
} catch {
|
|
2253
|
+
return match[1];
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
1732
2256
|
|
|
1733
2257
|
//#endregion
|
|
1734
2258
|
//#region src/routing/electric-proxy-router.ts
|
|
@@ -1736,134 +2260,33 @@ const electricProxyRouter = Router({ base: `/_electric/electric` });
|
|
|
1736
2260
|
electricProxyRouter.get(`/*`, proxyElectric);
|
|
1737
2261
|
async function proxyElectric(request, ctx) {
|
|
1738
2262
|
if (!ctx.electricUrl) return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`);
|
|
2263
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
|
|
1739
2264
|
const target = buildElectricProxyTarget({
|
|
1740
2265
|
incomingUrl: new URL(request.url),
|
|
1741
2266
|
electricUrl: ctx.electricUrl,
|
|
1742
2267
|
electricSecret: ctx.electricSecret,
|
|
1743
2268
|
tenantId: ctx.service,
|
|
1744
|
-
principalUrl: ctx.principal.url
|
|
2269
|
+
principalUrl: ctx.principal.url,
|
|
2270
|
+
principalKind: ctx.principal.kind,
|
|
2271
|
+
permissionBypass: isPermissionBypassPrincipal(ctx)
|
|
1745
2272
|
});
|
|
1746
2273
|
const headers = new Headers(request.headers);
|
|
1747
2274
|
headers.delete(`host`);
|
|
1748
2275
|
let upstream;
|
|
1749
2276
|
try {
|
|
1750
|
-
upstream = await fetch(target, {
|
|
1751
|
-
method: request.method,
|
|
1752
|
-
headers
|
|
1753
|
-
});
|
|
1754
|
-
} catch (err) {
|
|
1755
|
-
return apiError(502, `ELECTRIC_PROXY_FAILED`, err instanceof Error ? err.message : String(err));
|
|
1756
|
-
}
|
|
1757
|
-
return new Response(upstream.body, {
|
|
1758
|
-
status: upstream.status,
|
|
1759
|
-
statusText: upstream.statusText,
|
|
1760
|
-
headers: responseHeaders(upstream)
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
//#endregion
|
|
1765
|
-
//#region src/principal.ts
|
|
1766
|
-
const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
|
|
1767
|
-
const PRINCIPAL_KINDS = new Set([
|
|
1768
|
-
`user`,
|
|
1769
|
-
`agent`,
|
|
1770
|
-
`service`,
|
|
1771
|
-
`system`
|
|
1772
|
-
]);
|
|
1773
|
-
function parsePrincipalKey(input) {
|
|
1774
|
-
const colon = input.indexOf(`:`);
|
|
1775
|
-
if (colon <= 0) throw new Error(`Invalid principal identifier`);
|
|
1776
|
-
const kind = input.slice(0, colon);
|
|
1777
|
-
const id = input.slice(colon + 1);
|
|
1778
|
-
if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
|
|
1779
|
-
if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
|
|
1780
|
-
const key = `${kind}:${id}`;
|
|
1781
|
-
return {
|
|
1782
|
-
kind,
|
|
1783
|
-
id,
|
|
1784
|
-
key,
|
|
1785
|
-
url: `/principal/${encodeURIComponent(key)}`
|
|
1786
|
-
};
|
|
1787
|
-
}
|
|
1788
|
-
function principalUrl(key) {
|
|
1789
|
-
return parsePrincipalKey(key).url;
|
|
1790
|
-
}
|
|
1791
|
-
function parsePrincipalUrl(url) {
|
|
1792
|
-
if (!url.startsWith(`/principal/`)) return null;
|
|
1793
|
-
const segment = url.slice(`/principal/`.length);
|
|
1794
|
-
if (!segment || segment.includes(`/`)) return null;
|
|
1795
|
-
try {
|
|
1796
|
-
return parsePrincipalKey(decodeURIComponent(segment));
|
|
1797
|
-
} catch {
|
|
1798
|
-
return null;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
function parsePrincipalInput(input) {
|
|
1802
|
-
const urlPrincipal = parsePrincipalUrl(input);
|
|
1803
|
-
if (urlPrincipal) return urlPrincipal;
|
|
1804
|
-
try {
|
|
1805
|
-
return parsePrincipalKey(input);
|
|
1806
|
-
} catch {
|
|
1807
|
-
return null;
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
function getPrincipalFromRequest(request) {
|
|
1811
|
-
const value = request.headers.get(ELECTRIC_PRINCIPAL_HEADER);
|
|
1812
|
-
if (!value) return null;
|
|
1813
|
-
return parsePrincipalInput(value);
|
|
1814
|
-
}
|
|
1815
|
-
function getDevPrincipal() {
|
|
1816
|
-
return parsePrincipalKey(`system:dev-local`);
|
|
1817
|
-
}
|
|
1818
|
-
const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
|
|
1819
|
-
`framework`,
|
|
1820
|
-
`auth-sync`,
|
|
1821
|
-
`dev-local`
|
|
1822
|
-
]);
|
|
1823
|
-
function isBuiltInSystemPrincipalUrl(url) {
|
|
1824
|
-
if (!url?.startsWith(`/principal/`)) return false;
|
|
1825
|
-
try {
|
|
1826
|
-
const principal = parsePrincipalUrl(url);
|
|
1827
|
-
if (!principal) return false;
|
|
1828
|
-
return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
|
|
1829
|
-
} catch {
|
|
1830
|
-
return false;
|
|
2277
|
+
upstream = await fetch(target, {
|
|
2278
|
+
method: request.method,
|
|
2279
|
+
headers
|
|
2280
|
+
});
|
|
2281
|
+
} catch (err) {
|
|
2282
|
+
return apiError(502, `ELECTRIC_PROXY_FAILED`, err instanceof Error ? err.message : String(err));
|
|
1831
2283
|
}
|
|
2284
|
+
return new Response(upstream.body, {
|
|
2285
|
+
status: upstream.status,
|
|
2286
|
+
statusText: upstream.statusText,
|
|
2287
|
+
headers: responseHeaders(upstream)
|
|
2288
|
+
});
|
|
1832
2289
|
}
|
|
1833
|
-
function principalFromCreatedBy(createdBy) {
|
|
1834
|
-
if (!createdBy) return void 0;
|
|
1835
|
-
const principal = parsePrincipalUrl(createdBy);
|
|
1836
|
-
if (!principal) return {
|
|
1837
|
-
url: createdBy,
|
|
1838
|
-
key: null
|
|
1839
|
-
};
|
|
1840
|
-
return {
|
|
1841
|
-
url: principal.url,
|
|
1842
|
-
key: principal.key,
|
|
1843
|
-
kind: principal.kind,
|
|
1844
|
-
id: principal.id
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
const principalIdentityStateSchema = Type.Object({
|
|
1848
|
-
kind: Type.Union([
|
|
1849
|
-
Type.Literal(`user`),
|
|
1850
|
-
Type.Literal(`agent`),
|
|
1851
|
-
Type.Literal(`service`),
|
|
1852
|
-
Type.Literal(`system`)
|
|
1853
|
-
]),
|
|
1854
|
-
id: Type.String(),
|
|
1855
|
-
key: Type.String(),
|
|
1856
|
-
url: Type.String(),
|
|
1857
|
-
updated_at: Type.String(),
|
|
1858
|
-
display_name: Type.Optional(Type.String()),
|
|
1859
|
-
email: Type.Optional(Type.String()),
|
|
1860
|
-
avatar_url: Type.Optional(Type.String()),
|
|
1861
|
-
auth_provider: Type.Optional(Type.String()),
|
|
1862
|
-
auth_subject: Type.Optional(Type.String()),
|
|
1863
|
-
claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
1864
|
-
created_at: Type.Optional(Type.String())
|
|
1865
|
-
}, { additionalProperties: false });
|
|
1866
|
-
const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
|
|
1867
2290
|
|
|
1868
2291
|
//#endregion
|
|
1869
2292
|
//#region src/dispatch-policy-schema.ts
|
|
@@ -1889,6 +2312,125 @@ function parseDispatchPolicy(value, label = `dispatch_policy`) {
|
|
|
1889
2312
|
throw new Error(details ? `${label} does not match dispatch policy schema: ${details}` : `${label} does not match dispatch policy schema`);
|
|
1890
2313
|
}
|
|
1891
2314
|
|
|
2315
|
+
//#endregion
|
|
2316
|
+
//#region src/sandbox-choice-schema.ts
|
|
2317
|
+
/**
|
|
2318
|
+
* Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
|
|
2319
|
+
* the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
|
|
2320
|
+
* persisted on the entity. The matching `SandboxChoice` type is hand-maintained
|
|
2321
|
+
* in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
|
|
2322
|
+
* the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
|
|
2323
|
+
*
|
|
2324
|
+
* Validation happens once, at the router boundary (this schema is embedded in
|
|
2325
|
+
* the spawn body schema); the spawn resolver consumes already-validated input,
|
|
2326
|
+
* so there is intentionally no separate `parse` helper here.
|
|
2327
|
+
*/
|
|
2328
|
+
const sandboxChoiceSchema = Type.Object({
|
|
2329
|
+
profile: Type.Optional(Type.String()),
|
|
2330
|
+
key: Type.Optional(Type.String()),
|
|
2331
|
+
scope: Type.Optional(Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])),
|
|
2332
|
+
persistent: Type.Optional(Type.Boolean()),
|
|
2333
|
+
owner: Type.Optional(Type.Boolean()),
|
|
2334
|
+
inherit: Type.Optional(Type.Boolean())
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
//#endregion
|
|
2338
|
+
//#region src/routing/sandbox.ts
|
|
2339
|
+
/**
|
|
2340
|
+
* Resolve and validate a spawn's sandbox CHOICE into the {@link
|
|
2341
|
+
* EntitySandboxSelection} persisted on the entity. Sibling of
|
|
2342
|
+
* `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
|
|
2343
|
+
* EntityManager so the spawn path reads as composed resolution steps.
|
|
2344
|
+
*
|
|
2345
|
+
* Profiles are a per-runner concern: each runner advertises what it supports.
|
|
2346
|
+
* When the spawn pins a runner via dispatch_policy, the chosen profile must be
|
|
2347
|
+
* in that runner's advertised set; otherwise we'd persist an unserviceable
|
|
2348
|
+
* choice that fails late at first wake. For unpinned dispatch (webhook /
|
|
2349
|
+
* parent-inherited) we can't pick a target ahead of time, so we fall back to a
|
|
2350
|
+
* tenant-wide "some runner offers this" check — better than nothing.
|
|
2351
|
+
*/
|
|
2352
|
+
async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
|
|
2353
|
+
if (!requested) return void 0;
|
|
2354
|
+
const choice = applyInheritedSandbox(requested, parentEntity);
|
|
2355
|
+
if (!choice) return void 0;
|
|
2356
|
+
const chosenName = choice.profile;
|
|
2357
|
+
if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
|
|
2358
|
+
const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
|
|
2359
|
+
assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
|
|
2360
|
+
const selection = { profile: chosenName };
|
|
2361
|
+
if (choice.key !== void 0) selection.key = choice.key;
|
|
2362
|
+
else if (choice.scope !== void 0) selection.scope = choice.scope;
|
|
2363
|
+
if (choice.persistent !== void 0) selection.persistent = choice.persistent;
|
|
2364
|
+
if (choice.owner === false) selection.owner = false;
|
|
2365
|
+
return selection;
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
|
|
2369
|
+
* parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
|
|
2370
|
+
* parent has no shareable (keyed) sandbox the child simply gets none (returns
|
|
2371
|
+
* `undefined`), so `spawn_worker` can always request inheritance without
|
|
2372
|
+
* breaking unkeyed parents. (A running parent wake resolves inherit to its live
|
|
2373
|
+
* explicit key in the runtime instead — this server-side path covers direct API
|
|
2374
|
+
* callers, where only the parent's *stored* explicit key is available.)
|
|
2375
|
+
*
|
|
2376
|
+
* For a non-inherit choice the request passes through unchanged.
|
|
2377
|
+
*
|
|
2378
|
+
* NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
|
|
2379
|
+
* any sibling field on the request (e.g. a caller-supplied `persistent: false`)
|
|
2380
|
+
* is intentionally ignored, because a child attaches to the parent's existing
|
|
2381
|
+
* sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
|
|
2382
|
+
* permits the `{ inherit: true, persistent: ... }` combination, so the
|
|
2383
|
+
* precedence is resolved here rather than rejected at the schema level.
|
|
2384
|
+
*/
|
|
2385
|
+
function applyInheritedSandbox(requested, parentEntity) {
|
|
2386
|
+
if (!requested.inherit) return requested;
|
|
2387
|
+
const parentKey = parentEntity?.sandbox?.key;
|
|
2388
|
+
if (!parentKey) return void 0;
|
|
2389
|
+
return {
|
|
2390
|
+
profile: parentEntity.sandbox.profile,
|
|
2391
|
+
key: parentKey,
|
|
2392
|
+
persistent: parentEntity.sandbox.persistent,
|
|
2393
|
+
owner: false
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Validate the chosen profile is advertised by the relevant runner(s) and
|
|
2398
|
+
* determine whether it is a remote (off-host) sandbox, reachable from any
|
|
2399
|
+
* runner. Defaults to host-local (co-location required) unless every relevant
|
|
2400
|
+
* advertisement marks it remote. Throws if the profile is unserviceable.
|
|
2401
|
+
*/
|
|
2402
|
+
async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
|
|
2403
|
+
const runnerIds = [];
|
|
2404
|
+
for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
|
|
2405
|
+
if (runnerIds.length > 0) {
|
|
2406
|
+
let allRemote = true;
|
|
2407
|
+
for (const runnerId of runnerIds) {
|
|
2408
|
+
const runner = await registry.getRunner(runnerId);
|
|
2409
|
+
const advertised = runner?.sandbox_profiles ?? [];
|
|
2410
|
+
const match = advertised.find((p) => p.name === chosenName);
|
|
2411
|
+
if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
|
|
2412
|
+
if (match.remote !== true) allRemote = false;
|
|
2413
|
+
}
|
|
2414
|
+
return allRemote;
|
|
2415
|
+
}
|
|
2416
|
+
const available = await registry.listSandboxProfiles();
|
|
2417
|
+
const matches = available.filter((p) => p.name === chosenName);
|
|
2418
|
+
if (matches.length === 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not offered by any registered runner (available: ${[...new Set(available.map((p) => p.name))].join(`, `) || `(none)`}).`, 400);
|
|
2419
|
+
return matches.every((p) => p.remote === true);
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Co-location: a shared *local* sandbox lives on one host, so every
|
|
2423
|
+
* collaborator must be pinned to the same single runner. Subagents inherit the
|
|
2424
|
+
* parent's dispatch policy, so this holds once the root is pinned. A shared
|
|
2425
|
+
* *remote* sandbox is reachable from any runner, so the guard does not apply.
|
|
2426
|
+
*/
|
|
2427
|
+
function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
|
|
2428
|
+
if (key === void 0 || chosenIsRemote) return;
|
|
2429
|
+
const targets = dispatchPolicy?.targets ?? [];
|
|
2430
|
+
const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
|
|
2431
|
+
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);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
1892
2434
|
//#endregion
|
|
1893
2435
|
//#region src/tenant.ts
|
|
1894
2436
|
const DEFAULT_TENANT_ID = `default`;
|
|
@@ -1919,19 +2461,35 @@ function isDuplicateUrlError(err) {
|
|
|
1919
2461
|
return e.code === `23505`;
|
|
1920
2462
|
}
|
|
1921
2463
|
const DEFAULT_RUNNER_LEASE_MS = 3e4;
|
|
2464
|
+
const PERMISSION_PRUNE_INTERVAL_MS = 3e4;
|
|
1922
2465
|
function runnerWakeStream(runnerId) {
|
|
1923
2466
|
return `/runners/${runnerId}/wake`;
|
|
1924
2467
|
}
|
|
1925
2468
|
var PostgresRegistry = class {
|
|
2469
|
+
lastPermissionPruneStartedAt = 0;
|
|
2470
|
+
permissionPrunePromise = null;
|
|
1926
2471
|
constructor(db, tenantId = DEFAULT_TENANT_ID) {
|
|
1927
2472
|
this.db = db;
|
|
1928
2473
|
this.tenantId = tenantId;
|
|
1929
2474
|
}
|
|
1930
2475
|
async initialize() {}
|
|
1931
2476
|
close() {}
|
|
2477
|
+
async ensureUserForPrincipal(principal) {
|
|
2478
|
+
if (principal.kind !== `user`) return;
|
|
2479
|
+
await this.db.insert(users).values({
|
|
2480
|
+
tenantId: this.tenantId,
|
|
2481
|
+
id: principal.id
|
|
2482
|
+
}).onConflictDoNothing();
|
|
2483
|
+
}
|
|
1932
2484
|
async createRunner(input) {
|
|
1933
2485
|
const now = new Date();
|
|
1934
2486
|
const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
|
|
2487
|
+
const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
|
|
2488
|
+
name: p.name,
|
|
2489
|
+
label: p.label,
|
|
2490
|
+
...p.description !== void 0 && { description: p.description },
|
|
2491
|
+
...p.remote !== void 0 && { remote: p.remote }
|
|
2492
|
+
})) : void 0;
|
|
1935
2493
|
await this.db.insert(runners).values({
|
|
1936
2494
|
tenantId: this.tenantId,
|
|
1937
2495
|
id: input.id,
|
|
@@ -1940,6 +2498,7 @@ var PostgresRegistry = class {
|
|
|
1940
2498
|
kind: input.kind ?? `local`,
|
|
1941
2499
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
1942
2500
|
wakeStream,
|
|
2501
|
+
...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
|
|
1943
2502
|
updatedAt: now
|
|
1944
2503
|
}).onConflictDoUpdate({
|
|
1945
2504
|
target: [runners.tenantId, runners.id],
|
|
@@ -1949,6 +2508,7 @@ var PostgresRegistry = class {
|
|
|
1949
2508
|
kind: input.kind ?? `local`,
|
|
1950
2509
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
1951
2510
|
wakeStream,
|
|
2511
|
+
...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
|
|
1952
2512
|
updatedAt: now
|
|
1953
2513
|
}
|
|
1954
2514
|
});
|
|
@@ -1956,6 +2516,30 @@ var PostgresRegistry = class {
|
|
|
1956
2516
|
if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
|
|
1957
2517
|
return runner;
|
|
1958
2518
|
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Every sandbox profile advertised by a runner in this tenant (one entry
|
|
2521
|
+
* per runner that advertises it — names may repeat across runners). Used by
|
|
2522
|
+
* spawn validation for unpinned dispatch to learn whether a chosen profile
|
|
2523
|
+
* is remote (so a shared sandbox can skip the single-runner guard).
|
|
2524
|
+
*/
|
|
2525
|
+
async listSandboxProfiles() {
|
|
2526
|
+
const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where(eq(runners.tenantId, this.tenantId));
|
|
2527
|
+
const profiles = [];
|
|
2528
|
+
for (const row of rows) {
|
|
2529
|
+
const list = row.sandboxProfiles;
|
|
2530
|
+
if (!Array.isArray(list)) continue;
|
|
2531
|
+
for (const entry of list) {
|
|
2532
|
+
if (!entry || typeof entry.name !== `string`) continue;
|
|
2533
|
+
profiles.push({
|
|
2534
|
+
name: entry.name,
|
|
2535
|
+
label: typeof entry.label === `string` ? entry.label : entry.name,
|
|
2536
|
+
...typeof entry.description === `string` && { description: entry.description },
|
|
2537
|
+
...typeof entry.remote === `boolean` && { remote: entry.remote }
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return profiles;
|
|
2542
|
+
}
|
|
1959
2543
|
async getRunner(id) {
|
|
1960
2544
|
const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
|
|
1961
2545
|
return rows[0] ? this.rowToRunner(rows[0]) : null;
|
|
@@ -2216,6 +2800,7 @@ var PostgresRegistry = class {
|
|
|
2216
2800
|
tags: normalizeTags(entity.tags),
|
|
2217
2801
|
tagsIndex: buildTagsIndex(entity.tags),
|
|
2218
2802
|
spawnArgs: entity.spawn_args ?? {},
|
|
2803
|
+
sandbox: entity.sandbox ?? null,
|
|
2219
2804
|
parent: entity.parent ?? null,
|
|
2220
2805
|
createdBy: entity.created_by ?? null,
|
|
2221
2806
|
typeRevision: entity.type_revision ?? null,
|
|
@@ -2230,6 +2815,59 @@ var PostgresRegistry = class {
|
|
|
2230
2815
|
pendingSourceStreams: [],
|
|
2231
2816
|
updatedAt: new Date()
|
|
2232
2817
|
}).onConflictDoNothing();
|
|
2818
|
+
await tx.insert(entityLineage).values({
|
|
2819
|
+
tenantId: this.tenantId,
|
|
2820
|
+
ancestorUrl: entity.url,
|
|
2821
|
+
descendantUrl: entity.url,
|
|
2822
|
+
depth: 0
|
|
2823
|
+
}).onConflictDoNothing();
|
|
2824
|
+
if (entity.parent) await tx.execute(sql`
|
|
2825
|
+
INSERT INTO ${entityLineage} (
|
|
2826
|
+
tenant_id,
|
|
2827
|
+
ancestor_url,
|
|
2828
|
+
descendant_url,
|
|
2829
|
+
depth
|
|
2830
|
+
)
|
|
2831
|
+
SELECT
|
|
2832
|
+
${this.tenantId},
|
|
2833
|
+
ancestor_url,
|
|
2834
|
+
${entity.url},
|
|
2835
|
+
depth + 1
|
|
2836
|
+
FROM ${entityLineage}
|
|
2837
|
+
WHERE tenant_id = ${this.tenantId}
|
|
2838
|
+
AND descendant_url = ${entity.parent}
|
|
2839
|
+
ON CONFLICT DO NOTHING
|
|
2840
|
+
`);
|
|
2841
|
+
await tx.execute(sql`
|
|
2842
|
+
INSERT INTO ${entityEffectivePermissions} (
|
|
2843
|
+
tenant_id,
|
|
2844
|
+
entity_url,
|
|
2845
|
+
source_entity_url,
|
|
2846
|
+
source_grant_id,
|
|
2847
|
+
permission,
|
|
2848
|
+
subject_kind,
|
|
2849
|
+
subject_value,
|
|
2850
|
+
expires_at
|
|
2851
|
+
)
|
|
2852
|
+
SELECT
|
|
2853
|
+
${this.tenantId},
|
|
2854
|
+
${entity.url},
|
|
2855
|
+
grants.entity_url,
|
|
2856
|
+
grants.id,
|
|
2857
|
+
grants.permission,
|
|
2858
|
+
grants.subject_kind,
|
|
2859
|
+
grants.subject_value,
|
|
2860
|
+
grants.expires_at
|
|
2861
|
+
FROM ${entityPermissionGrants} grants
|
|
2862
|
+
JOIN ${entityLineage} lineage
|
|
2863
|
+
ON lineage.tenant_id = grants.tenant_id
|
|
2864
|
+
AND lineage.ancestor_url = grants.entity_url
|
|
2865
|
+
AND lineage.descendant_url = ${entity.url}
|
|
2866
|
+
WHERE grants.tenant_id = ${this.tenantId}
|
|
2867
|
+
AND grants.propagation = 'descendants'
|
|
2868
|
+
AND (grants.expires_at IS NULL OR grants.expires_at > now())
|
|
2869
|
+
ON CONFLICT DO NOTHING
|
|
2870
|
+
`);
|
|
2233
2871
|
return parseInt(result[0].txid);
|
|
2234
2872
|
});
|
|
2235
2873
|
} catch (err) {
|
|
@@ -2251,10 +2889,8 @@ var PostgresRegistry = class {
|
|
|
2251
2889
|
}
|
|
2252
2890
|
async getEntityByStream(streamPath) {
|
|
2253
2891
|
const mainSuffix = `/main`;
|
|
2254
|
-
const errorSuffix = `/error`;
|
|
2255
2892
|
let entityUrl = null;
|
|
2256
2893
|
if (streamPath.endsWith(mainSuffix)) entityUrl = streamPath.slice(0, -mainSuffix.length);
|
|
2257
|
-
else if (streamPath.endsWith(errorSuffix)) entityUrl = streamPath.slice(0, -errorSuffix.length);
|
|
2258
2894
|
if (!entityUrl) return null;
|
|
2259
2895
|
return this.getEntity(entityUrl);
|
|
2260
2896
|
}
|
|
@@ -2264,6 +2900,23 @@ var PostgresRegistry = class {
|
|
|
2264
2900
|
if (filter?.status) conditions.push(eq(entities.status, filter.status));
|
|
2265
2901
|
if (filter?.parent) conditions.push(eq(entities.parent, filter.parent));
|
|
2266
2902
|
if (filter?.created_by) conditions.push(eq(entities.createdBy, filter.created_by));
|
|
2903
|
+
if (filter?.readableBy && !filter.readableBy.bypass) conditions.push(sql`(
|
|
2904
|
+
${entities.createdBy} = ${filter.readableBy.principalUrl}
|
|
2905
|
+
OR ${entities.url} IN (
|
|
2906
|
+
SELECT ${entityEffectivePermissions.entityUrl}
|
|
2907
|
+
FROM ${entityEffectivePermissions}
|
|
2908
|
+
WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
|
|
2909
|
+
AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
|
|
2910
|
+
AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
|
|
2911
|
+
AND (
|
|
2912
|
+
(${entityEffectivePermissions.subjectKind} = 'principal'
|
|
2913
|
+
AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
|
|
2914
|
+
OR
|
|
2915
|
+
(${entityEffectivePermissions.subjectKind} = 'principal_kind'
|
|
2916
|
+
AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
|
|
2917
|
+
)
|
|
2918
|
+
)
|
|
2919
|
+
)`);
|
|
2267
2920
|
const whereClause = and(...conditions);
|
|
2268
2921
|
const countResult = await this.db.select({ count: sql`count(*)` }).from(entities).where(whereClause);
|
|
2269
2922
|
const total = Number(countResult[0].count);
|
|
@@ -2276,6 +2929,189 @@ var PostgresRegistry = class {
|
|
|
2276
2929
|
total
|
|
2277
2930
|
};
|
|
2278
2931
|
}
|
|
2932
|
+
async createEntityTypePermissionGrant(input) {
|
|
2933
|
+
const [row] = await this.db.insert(entityTypePermissionGrants).values({
|
|
2934
|
+
tenantId: this.tenantId,
|
|
2935
|
+
entityType: input.entityType,
|
|
2936
|
+
permission: input.permission,
|
|
2937
|
+
subjectKind: input.subjectKind,
|
|
2938
|
+
subjectValue: input.subjectValue,
|
|
2939
|
+
createdBy: input.createdBy ?? null,
|
|
2940
|
+
expiresAt: input.expiresAt ?? null
|
|
2941
|
+
}).returning();
|
|
2942
|
+
return this.rowToEntityTypePermissionGrant(row);
|
|
2943
|
+
}
|
|
2944
|
+
async ensureEntityTypePermissionGrant(input) {
|
|
2945
|
+
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);
|
|
2946
|
+
if (existing) return this.rowToEntityTypePermissionGrant(existing);
|
|
2947
|
+
return await this.createEntityTypePermissionGrant(input);
|
|
2948
|
+
}
|
|
2949
|
+
async listEntityTypePermissionGrants(entityType) {
|
|
2950
|
+
const rows = await this.db.select().from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType))).orderBy(entityTypePermissionGrants.id);
|
|
2951
|
+
return rows.map((row) => this.rowToEntityTypePermissionGrant(row));
|
|
2952
|
+
}
|
|
2953
|
+
async deleteEntityTypePermissionGrant(entityType, grantId) {
|
|
2954
|
+
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 });
|
|
2955
|
+
return rows.length > 0;
|
|
2956
|
+
}
|
|
2957
|
+
async hasEntityTypePermission(entityType, permission, subject) {
|
|
2958
|
+
const permissions = [permission, `manage`];
|
|
2959
|
+
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`(
|
|
2960
|
+
(${entityTypePermissionGrants.subjectKind} = 'principal'
|
|
2961
|
+
AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
|
|
2962
|
+
OR
|
|
2963
|
+
(${entityTypePermissionGrants.subjectKind} = 'principal_kind'
|
|
2964
|
+
AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
|
|
2965
|
+
)`)).limit(1);
|
|
2966
|
+
return rows.length > 0;
|
|
2967
|
+
}
|
|
2968
|
+
async createEntityPermissionGrant(input) {
|
|
2969
|
+
return await this.db.transaction(async (tx) => {
|
|
2970
|
+
const [row] = await tx.insert(entityPermissionGrants).values({
|
|
2971
|
+
tenantId: this.tenantId,
|
|
2972
|
+
entityUrl: input.entityUrl,
|
|
2973
|
+
permission: input.permission,
|
|
2974
|
+
subjectKind: input.subjectKind,
|
|
2975
|
+
subjectValue: input.subjectValue,
|
|
2976
|
+
propagation: input.propagation ?? `self`,
|
|
2977
|
+
copyToChildren: input.copyToChildren ?? false,
|
|
2978
|
+
createdBy: input.createdBy ?? null,
|
|
2979
|
+
expiresAt: input.expiresAt ?? null
|
|
2980
|
+
}).returning();
|
|
2981
|
+
await this.materializeEntityPermissionGrant(tx, row);
|
|
2982
|
+
return this.rowToEntityPermissionGrant(row);
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
async listEntityPermissionGrants(entityUrl) {
|
|
2986
|
+
const rows = await this.db.select().from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, entityUrl))).orderBy(entityPermissionGrants.id);
|
|
2987
|
+
return rows.map((row) => this.rowToEntityPermissionGrant(row));
|
|
2988
|
+
}
|
|
2989
|
+
async deleteEntityPermissionGrant(entityUrl, grantId) {
|
|
2990
|
+
return await this.db.transaction(async (tx) => {
|
|
2991
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grantId)));
|
|
2992
|
+
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 });
|
|
2993
|
+
return rows.length > 0;
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
async copyEntityPermissionGrantsForSpawn(parentEntityUrl, childEntityUrl, createdBy) {
|
|
2997
|
+
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())`));
|
|
2998
|
+
const copied = [];
|
|
2999
|
+
for (const grant of parentGrants) copied.push(await this.createEntityPermissionGrant({
|
|
3000
|
+
entityUrl: childEntityUrl,
|
|
3001
|
+
permission: grant.permission,
|
|
3002
|
+
subjectKind: grant.subjectKind,
|
|
3003
|
+
subjectValue: grant.subjectValue,
|
|
3004
|
+
propagation: `self`,
|
|
3005
|
+
copyToChildren: grant.copyToChildren,
|
|
3006
|
+
createdBy,
|
|
3007
|
+
expiresAt: grant.expiresAt ?? void 0
|
|
3008
|
+
}));
|
|
3009
|
+
return copied;
|
|
3010
|
+
}
|
|
3011
|
+
async hasEntityPermission(entityUrl, permission, subject) {
|
|
3012
|
+
const permissions = [permission, `manage`];
|
|
3013
|
+
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`(
|
|
3014
|
+
(${entityEffectivePermissions.subjectKind} = 'principal'
|
|
3015
|
+
AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
|
|
3016
|
+
OR
|
|
3017
|
+
(${entityEffectivePermissions.subjectKind} = 'principal_kind'
|
|
3018
|
+
AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
|
|
3019
|
+
)`)).limit(1);
|
|
3020
|
+
return rows.length > 0;
|
|
3021
|
+
}
|
|
3022
|
+
async replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId) {
|
|
3023
|
+
await this.db.delete(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl), eq(sharedStateLinks.manifestKey, manifestKey)));
|
|
3024
|
+
if (!sharedStateId) return;
|
|
3025
|
+
await this.db.insert(sharedStateLinks).values({
|
|
3026
|
+
tenantId: this.tenantId,
|
|
3027
|
+
ownerEntityUrl,
|
|
3028
|
+
manifestKey,
|
|
3029
|
+
sharedStateId
|
|
3030
|
+
}).onConflictDoUpdate({
|
|
3031
|
+
target: [
|
|
3032
|
+
sharedStateLinks.tenantId,
|
|
3033
|
+
sharedStateLinks.ownerEntityUrl,
|
|
3034
|
+
sharedStateLinks.manifestKey
|
|
3035
|
+
],
|
|
3036
|
+
set: {
|
|
3037
|
+
sharedStateId,
|
|
3038
|
+
updatedAt: new Date()
|
|
3039
|
+
}
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
async listSharedStateLinkedEntityUrls(sharedStateId) {
|
|
3043
|
+
const rows = await this.db.selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl }).from(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.sharedStateId, sharedStateId)));
|
|
3044
|
+
return rows.map((row) => row.ownerEntityUrl);
|
|
3045
|
+
}
|
|
3046
|
+
async pruneExpiredPermissionGrants(now = new Date(), options = {}) {
|
|
3047
|
+
if (this.permissionPrunePromise) return await this.permissionPrunePromise;
|
|
3048
|
+
const startedAt = Date.now();
|
|
3049
|
+
if (!options.force && startedAt - this.lastPermissionPruneStartedAt < PERMISSION_PRUNE_INTERVAL_MS) return;
|
|
3050
|
+
this.lastPermissionPruneStartedAt = startedAt;
|
|
3051
|
+
const promise = this.pruneExpiredPermissionGrantsNow(now);
|
|
3052
|
+
this.permissionPrunePromise = promise;
|
|
3053
|
+
try {
|
|
3054
|
+
await promise;
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
this.lastPermissionPruneStartedAt = 0;
|
|
3057
|
+
throw error;
|
|
3058
|
+
} finally {
|
|
3059
|
+
if (this.permissionPrunePromise === promise) this.permissionPrunePromise = null;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
async pruneExpiredPermissionGrantsNow(now) {
|
|
3063
|
+
await this.db.transaction(async (tx) => {
|
|
3064
|
+
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)));
|
|
3065
|
+
const ids = expiredEntityGrantIds.map((row) => row.id);
|
|
3066
|
+
if (ids.length > 0) {
|
|
3067
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), inArray(entityEffectivePermissions.sourceGrantId, ids)));
|
|
3068
|
+
await tx.delete(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), inArray(entityPermissionGrants.id, ids)));
|
|
3069
|
+
}
|
|
3070
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`, lt(entityEffectivePermissions.expiresAt, now)));
|
|
3071
|
+
await tx.delete(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`, lt(entityTypePermissionGrants.expiresAt, now)));
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
async materializeEntityPermissionGrant(tx, grant) {
|
|
3075
|
+
await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grant.id)));
|
|
3076
|
+
if (grant.propagation === `descendants`) {
|
|
3077
|
+
await tx.execute(sql`
|
|
3078
|
+
INSERT INTO ${entityEffectivePermissions} (
|
|
3079
|
+
tenant_id,
|
|
3080
|
+
entity_url,
|
|
3081
|
+
source_entity_url,
|
|
3082
|
+
source_grant_id,
|
|
3083
|
+
permission,
|
|
3084
|
+
subject_kind,
|
|
3085
|
+
subject_value,
|
|
3086
|
+
expires_at
|
|
3087
|
+
)
|
|
3088
|
+
SELECT
|
|
3089
|
+
${this.tenantId},
|
|
3090
|
+
descendant_url,
|
|
3091
|
+
${grant.entityUrl},
|
|
3092
|
+
${grant.id},
|
|
3093
|
+
${grant.permission},
|
|
3094
|
+
${grant.subjectKind},
|
|
3095
|
+
${grant.subjectValue},
|
|
3096
|
+
${grant.expiresAt}
|
|
3097
|
+
FROM ${entityLineage}
|
|
3098
|
+
WHERE tenant_id = ${this.tenantId}
|
|
3099
|
+
AND ancestor_url = ${grant.entityUrl}
|
|
3100
|
+
ON CONFLICT DO NOTHING
|
|
3101
|
+
`);
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
await tx.insert(entityEffectivePermissions).values({
|
|
3105
|
+
tenantId: this.tenantId,
|
|
3106
|
+
entityUrl: grant.entityUrl,
|
|
3107
|
+
sourceEntityUrl: grant.entityUrl,
|
|
3108
|
+
sourceGrantId: grant.id,
|
|
3109
|
+
permission: grant.permission,
|
|
3110
|
+
subjectKind: grant.subjectKind,
|
|
3111
|
+
subjectValue: grant.subjectValue,
|
|
3112
|
+
expiresAt: grant.expiresAt
|
|
3113
|
+
}).onConflictDoNothing();
|
|
3114
|
+
}
|
|
2279
3115
|
async updateStatus(entityUrl, status$1) {
|
|
2280
3116
|
const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
|
|
2281
3117
|
await this.db.update(entities).set({
|
|
@@ -2377,7 +3213,9 @@ var PostgresRegistry = class {
|
|
|
2377
3213
|
tenantId: this.tenantId,
|
|
2378
3214
|
sourceRef: row.sourceRef,
|
|
2379
3215
|
tags: normalizeTags(row.tags),
|
|
2380
|
-
streamUrl: row.streamUrl
|
|
3216
|
+
streamUrl: row.streamUrl,
|
|
3217
|
+
principalUrl: row.principalUrl,
|
|
3218
|
+
principalKind: row.principalKind
|
|
2381
3219
|
}).onConflictDoNothing();
|
|
2382
3220
|
const existing = await this.getEntityBridge(row.sourceRef);
|
|
2383
3221
|
if (!existing) throw new Error(`Failed to load entity bridge ${row.sourceRef}`);
|
|
@@ -2539,20 +3377,46 @@ var PostgresRegistry = class {
|
|
|
2539
3377
|
updated_at: row.updatedAt
|
|
2540
3378
|
};
|
|
2541
3379
|
}
|
|
3380
|
+
rowToEntityTypePermissionGrant(row) {
|
|
3381
|
+
return {
|
|
3382
|
+
id: row.id,
|
|
3383
|
+
entity_type: row.entityType,
|
|
3384
|
+
permission: row.permission,
|
|
3385
|
+
subject_kind: row.subjectKind,
|
|
3386
|
+
subject_value: row.subjectValue,
|
|
3387
|
+
created_by: row.createdBy ?? void 0,
|
|
3388
|
+
expires_at: row.expiresAt?.toISOString(),
|
|
3389
|
+
created_at: row.createdAt.toISOString(),
|
|
3390
|
+
updated_at: row.updatedAt.toISOString()
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
rowToEntityPermissionGrant(row) {
|
|
3394
|
+
return {
|
|
3395
|
+
id: row.id,
|
|
3396
|
+
entity_url: row.entityUrl,
|
|
3397
|
+
permission: row.permission,
|
|
3398
|
+
subject_kind: row.subjectKind,
|
|
3399
|
+
subject_value: row.subjectValue,
|
|
3400
|
+
propagation: row.propagation,
|
|
3401
|
+
copy_to_children: row.copyToChildren,
|
|
3402
|
+
created_by: row.createdBy ?? void 0,
|
|
3403
|
+
expires_at: row.expiresAt?.toISOString(),
|
|
3404
|
+
created_at: row.createdAt.toISOString(),
|
|
3405
|
+
updated_at: row.updatedAt.toISOString()
|
|
3406
|
+
};
|
|
3407
|
+
}
|
|
2542
3408
|
rowToEntity(row) {
|
|
2543
3409
|
return {
|
|
2544
3410
|
url: row.url,
|
|
2545
3411
|
type: row.type,
|
|
2546
3412
|
status: assertEntityStatus(row.status),
|
|
2547
|
-
streams: {
|
|
2548
|
-
main: `${row.url}/main`,
|
|
2549
|
-
error: `${row.url}/error`
|
|
2550
|
-
},
|
|
3413
|
+
streams: { main: `${row.url}/main` },
|
|
2551
3414
|
subscription_id: row.subscriptionId,
|
|
2552
3415
|
dispatch_policy: row.dispatchPolicy ?? void 0,
|
|
2553
3416
|
write_token: row.writeToken,
|
|
2554
3417
|
tags: row.tags ?? {},
|
|
2555
3418
|
spawn_args: row.spawnArgs,
|
|
3419
|
+
sandbox: row.sandbox ?? void 0,
|
|
2556
3420
|
parent: row.parent ?? void 0,
|
|
2557
3421
|
created_by: row.createdBy ?? void 0,
|
|
2558
3422
|
type_revision: row.typeRevision ?? void 0,
|
|
@@ -2568,6 +3432,8 @@ var PostgresRegistry = class {
|
|
|
2568
3432
|
sourceRef: row.sourceRef,
|
|
2569
3433
|
tags: row.tags ?? {},
|
|
2570
3434
|
streamUrl: row.streamUrl,
|
|
3435
|
+
principalUrl: row.principalUrl ?? void 0,
|
|
3436
|
+
principalKind: row.principalKind ?? void 0,
|
|
2571
3437
|
shapeHandle: row.shapeHandle ?? void 0,
|
|
2572
3438
|
shapeOffset: row.shapeOffset ?? void 0,
|
|
2573
3439
|
lastObserverActivityAt: row.lastObserverActivityAt,
|
|
@@ -2600,6 +3466,7 @@ var PostgresRegistry = class {
|
|
|
2600
3466
|
kind: assertRunnerKind(row.kind),
|
|
2601
3467
|
admin_status: assertRunnerAdminStatus(row.adminStatus),
|
|
2602
3468
|
wake_stream: row.wakeStream,
|
|
3469
|
+
sandbox_profiles: row.sandboxProfiles ?? [],
|
|
2603
3470
|
created_at: row.createdAt.toISOString(),
|
|
2604
3471
|
updated_at: row.updatedAt.toISOString()
|
|
2605
3472
|
};
|
|
@@ -2869,7 +3736,10 @@ var EntityManager = class {
|
|
|
2869
3736
|
}
|
|
2870
3737
|
async ensurePrincipal(principal) {
|
|
2871
3738
|
const existing = await this.registry.getEntity(principal.url);
|
|
2872
|
-
if (existing)
|
|
3739
|
+
if (existing) {
|
|
3740
|
+
await this.ensureUserPrincipal(principal);
|
|
3741
|
+
return existing;
|
|
3742
|
+
}
|
|
2873
3743
|
await this.ensurePrincipalEntityType();
|
|
2874
3744
|
try {
|
|
2875
3745
|
const entity = await this.spawn(`principal`, {
|
|
@@ -2898,15 +3768,22 @@ var EntityManager = class {
|
|
|
2898
3768
|
updated_at: now
|
|
2899
3769
|
}
|
|
2900
3770
|
}));
|
|
3771
|
+
await this.ensureUserPrincipal(principal);
|
|
2901
3772
|
return entity;
|
|
2902
3773
|
} catch (error) {
|
|
2903
3774
|
if (error instanceof ElectricAgentsError && error.code === ErrCodeDuplicateURL) {
|
|
2904
3775
|
const raced = await this.registry.getEntity(principal.url);
|
|
2905
|
-
if (raced)
|
|
3776
|
+
if (raced) {
|
|
3777
|
+
await this.ensureUserPrincipal(principal);
|
|
3778
|
+
return raced;
|
|
3779
|
+
}
|
|
2906
3780
|
}
|
|
2907
3781
|
throw error;
|
|
2908
3782
|
}
|
|
2909
3783
|
}
|
|
3784
|
+
async ensureUserPrincipal(principal) {
|
|
3785
|
+
if (principal.kind === `user`) await this.registry.ensureUserForPrincipal(principal);
|
|
3786
|
+
}
|
|
2910
3787
|
/**
|
|
2911
3788
|
* Spawn a new entity of the given type with durable streams.
|
|
2912
3789
|
*/
|
|
@@ -2936,7 +3813,6 @@ var EntityManager = class {
|
|
|
2936
3813
|
const writeToken = randomUUID();
|
|
2937
3814
|
const entityURL = typeName === `principal` ? principalUrl(instanceId) : `/${typeName}/${instanceId}`;
|
|
2938
3815
|
const mainPath = `${entityURL}/main`;
|
|
2939
|
-
const errorPath = `${entityURL}/error`;
|
|
2940
3816
|
const subscriptionId = `${typeName}-handler`;
|
|
2941
3817
|
const spawnT0 = performance.now();
|
|
2942
3818
|
const existingByURL = await this.registry.getEntity(entityURL);
|
|
@@ -2947,20 +3823,19 @@ var EntityManager = class {
|
|
|
2947
3823
|
if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
|
|
2948
3824
|
}
|
|
2949
3825
|
const dispatchPolicy = req.dispatch_policy ? this.validateDispatchPolicy(req.dispatch_policy, { label: `dispatch_policy` }) : parentEntity?.dispatch_policy ? applyTypeDefaultSubscriptionScope(parentEntity.dispatch_policy, entityType.default_dispatch_policy) : entityType.default_dispatch_policy;
|
|
3826
|
+
const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
|
|
2950
3827
|
const now = Date.now();
|
|
2951
3828
|
const entityData = {
|
|
2952
3829
|
type: typeName,
|
|
2953
3830
|
status: `idle`,
|
|
2954
3831
|
url: entityURL,
|
|
2955
|
-
streams: {
|
|
2956
|
-
main: mainPath,
|
|
2957
|
-
error: errorPath
|
|
2958
|
-
},
|
|
3832
|
+
streams: { main: mainPath },
|
|
2959
3833
|
subscription_id: subscriptionId,
|
|
2960
3834
|
dispatch_policy: dispatchPolicy,
|
|
2961
3835
|
write_token: writeToken,
|
|
2962
3836
|
tags: initialTags,
|
|
2963
3837
|
spawn_args: req.args,
|
|
3838
|
+
sandbox,
|
|
2964
3839
|
type_revision: entityType.revision,
|
|
2965
3840
|
inbox_schemas: entityType.inbox_schemas,
|
|
2966
3841
|
state_schemas: entityType.state_schemas,
|
|
@@ -3007,55 +3882,43 @@ var EntityManager = class {
|
|
|
3007
3882
|
const queueEnterT0 = performance.now();
|
|
3008
3883
|
const queueWaiting = this.spawnPersistQueue.length();
|
|
3009
3884
|
const queueRunning = this.spawnPersistQueue.running();
|
|
3010
|
-
const [mainStreamResult,
|
|
3885
|
+
const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
|
|
3011
3886
|
let entityTxid;
|
|
3012
3887
|
try {
|
|
3013
3888
|
entityTxid = await withSpan(`db.createEntity`, () => this.registry.createEntity(entityData));
|
|
3014
3889
|
} catch (err) {
|
|
3015
|
-
return [
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
value: void 0
|
|
3023
|
-
},
|
|
3024
|
-
{
|
|
3025
|
-
status: `rejected`,
|
|
3026
|
-
reason: err
|
|
3027
|
-
}
|
|
3028
|
-
];
|
|
3890
|
+
return [{
|
|
3891
|
+
status: `fulfilled`,
|
|
3892
|
+
value: void 0
|
|
3893
|
+
}, {
|
|
3894
|
+
status: `rejected`,
|
|
3895
|
+
reason: err
|
|
3896
|
+
}];
|
|
3029
3897
|
}
|
|
3030
|
-
const [mainStreamResult$1
|
|
3898
|
+
const [mainStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
|
|
3031
3899
|
contentType,
|
|
3032
3900
|
body: initialBody
|
|
3033
|
-
})
|
|
3034
|
-
return [
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
status: `fulfilled`,
|
|
3039
|
-
value: entityTxid
|
|
3040
|
-
}
|
|
3041
|
-
];
|
|
3901
|
+
})]);
|
|
3902
|
+
return [mainStreamResult$1, {
|
|
3903
|
+
status: `fulfilled`,
|
|
3904
|
+
value: entityTxid
|
|
3905
|
+
}];
|
|
3042
3906
|
});
|
|
3043
3907
|
const parallelMs = +(performance.now() - queueEnterT0).toFixed(2);
|
|
3044
|
-
if (mainStreamResult.status === `rejected` ||
|
|
3908
|
+
if (mainStreamResult.status === `rejected` || entityResult.status === `rejected`) {
|
|
3045
3909
|
const entityReason = entityResult.status === `rejected` ? entityResult.reason : null;
|
|
3046
|
-
const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason :
|
|
3910
|
+
const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : null;
|
|
3047
3911
|
const isDuplicate = entityReason instanceof EntityAlreadyExistsError;
|
|
3048
3912
|
const isStreamConflict = !!streamReason && typeof streamReason === `object` && (`status` in streamReason && streamReason.status === 409 || `code` in streamReason && streamReason.code === `CONFLICT_SEQ`);
|
|
3049
3913
|
const rollbacks = [];
|
|
3050
3914
|
if (!isDuplicate && !isStreamConflict) {
|
|
3051
3915
|
if (mainStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(mainPath));
|
|
3052
|
-
if (errorStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(errorPath));
|
|
3053
3916
|
if (entityResult.status === `fulfilled`) rollbacks.push(this.registry.deleteEntity(entityURL));
|
|
3054
3917
|
if (req.wake) rollbacks.push(this.wakeRegistry.unregisterBySubscriberAndSource(req.wake.subscriberUrl, entityURL, this.tenantId));
|
|
3055
3918
|
await Promise.allSettled(rollbacks);
|
|
3056
3919
|
}
|
|
3057
3920
|
if (isDuplicate || isStreamConflict) throw new ElectricAgentsError(ErrCodeDuplicateURL, `Entity already exists at URL "${entityURL}"`, 409);
|
|
3058
|
-
const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason :
|
|
3921
|
+
const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : entityResult.reason;
|
|
3059
3922
|
if (failure instanceof Error) throw failure;
|
|
3060
3923
|
throw new ElectricAgentsError(`SPAWN_FAILED`, `Spawn failed: ${String(failure)}`, 500);
|
|
3061
3924
|
}
|
|
@@ -3090,30 +3953,67 @@ var EntityManager = class {
|
|
|
3090
3953
|
const writeEntityLocks = new Set();
|
|
3091
3954
|
const writeStreamLocks = new Set();
|
|
3092
3955
|
try {
|
|
3093
|
-
|
|
3956
|
+
let sourceTree;
|
|
3957
|
+
if (opts.forkPointer) {
|
|
3958
|
+
const rootEntity = await this.registry.getEntity(rootUrl);
|
|
3959
|
+
if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3960
|
+
if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
|
|
3961
|
+
sourceTree = await this.listEntitySubtree(rootEntity);
|
|
3962
|
+
} else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
|
|
3094
3963
|
const sourceRoot = sourceTree[0];
|
|
3095
3964
|
if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3096
|
-
|
|
3965
|
+
let preFilteredRoot;
|
|
3966
|
+
if (opts.forkPointer) {
|
|
3967
|
+
const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
|
|
3968
|
+
const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
|
|
3969
|
+
const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
|
|
3970
|
+
const filteredEvents = flat.slice(0, target);
|
|
3971
|
+
const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
|
|
3972
|
+
const sharedStateIds = new Set();
|
|
3973
|
+
for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
|
|
3974
|
+
preFilteredRoot = {
|
|
3975
|
+
manifests: rootManifests,
|
|
3976
|
+
childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
|
|
3977
|
+
replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
|
|
3978
|
+
sharedStateIds
|
|
3979
|
+
};
|
|
3980
|
+
}
|
|
3981
|
+
const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
|
|
3982
|
+
if (opts.forkPointer) {
|
|
3983
|
+
const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
|
|
3984
|
+
if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
|
|
3985
|
+
}
|
|
3986
|
+
const snapshot = await this.readForkStateSnapshot(
|
|
3987
|
+
// Skip the root when we've already pre-filtered it — avoid both a
|
|
3988
|
+
// wasted HEAD read of main and a re-population that would clobber
|
|
3989
|
+
// the filtered entries.
|
|
3990
|
+
preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
|
|
3991
|
+
);
|
|
3992
|
+
if (preFilteredRoot) {
|
|
3993
|
+
snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
|
|
3994
|
+
snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
|
|
3995
|
+
snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
|
|
3996
|
+
for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
|
|
3997
|
+
}
|
|
3097
3998
|
const suffix = randomUUID().slice(0, 8);
|
|
3098
|
-
const entityUrlMap = await this.buildForkEntityUrlMap(
|
|
3999
|
+
const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
|
|
3099
4000
|
suffix,
|
|
3100
4001
|
rootUrl,
|
|
3101
4002
|
rootInstanceId: opts.rootInstanceId
|
|
3102
4003
|
});
|
|
3103
4004
|
const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
|
|
3104
4005
|
const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
|
|
3105
|
-
const entityPlans = this.buildForkEntityPlans(
|
|
3106
|
-
this.addForkLocks(this.forkWriteLockedEntities,
|
|
4006
|
+
const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
|
|
4007
|
+
this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
|
|
3107
4008
|
this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
|
|
3108
4009
|
const createdStreams = [];
|
|
3109
4010
|
const createdEntities = [];
|
|
3110
4011
|
const activeManifestsByEntity = new Map();
|
|
3111
4012
|
try {
|
|
3112
4013
|
for (const plan of entityPlans) {
|
|
3113
|
-
|
|
4014
|
+
const isRoot = plan.source.url === rootUrl;
|
|
4015
|
+
await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
|
|
3114
4016
|
createdStreams.push(plan.fork.streams.main);
|
|
3115
|
-
await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
|
|
3116
|
-
createdStreams.push(plan.fork.streams.error);
|
|
3117
4017
|
}
|
|
3118
4018
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
3119
4019
|
const sourcePath = getSharedStateStreamPath(sourceId);
|
|
@@ -3203,6 +4103,38 @@ var EntityManager = class {
|
|
|
3203
4103
|
}
|
|
3204
4104
|
held.clear();
|
|
3205
4105
|
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Variant of {@link waitForIdleSubtree} that takes an explicit entity
|
|
4108
|
+
* list instead of walking the registry from `rootUrl`. Used by the
|
|
4109
|
+
* pointer-fork path to wait+lock only the kept descendants, since
|
|
4110
|
+
* the root is being forked from history and doesn't need to be idle.
|
|
4111
|
+
*/
|
|
4112
|
+
async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
|
|
4113
|
+
if (entities$1.length === 0) return;
|
|
4114
|
+
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
|
|
4115
|
+
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
|
|
4116
|
+
const refresh = async () => {
|
|
4117
|
+
const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
|
|
4118
|
+
return refreshed.filter((entity) => !!entity);
|
|
4119
|
+
};
|
|
4120
|
+
const deadline = Date.now() + timeoutMs;
|
|
4121
|
+
while (true) {
|
|
4122
|
+
const present = await refresh();
|
|
4123
|
+
const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
|
|
4124
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
4125
|
+
let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
4126
|
+
if (active.length === 0) {
|
|
4127
|
+
this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
|
|
4128
|
+
const reChecked = await refresh();
|
|
4129
|
+
const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
4130
|
+
if (reActive.length === 0) return;
|
|
4131
|
+
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
4132
|
+
active = reActive;
|
|
4133
|
+
}
|
|
4134
|
+
if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
|
|
4135
|
+
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
3206
4138
|
async waitForIdleSubtree(rootUrl, opts, workLocks) {
|
|
3207
4139
|
const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
|
|
3208
4140
|
const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
|
|
@@ -3232,6 +4164,73 @@ var EntityManager = class {
|
|
|
3232
4164
|
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
3233
4165
|
}
|
|
3234
4166
|
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
|
|
4169
|
+
* source's flattened history. Throws a 400 if the pointer doesn't
|
|
4170
|
+
* address a real event.
|
|
4171
|
+
*
|
|
4172
|
+
* Semantics (mirroring the durable-streams server interpretation):
|
|
4173
|
+
* `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
|
|
4174
|
+
* messages forward." Concretely, the target event is the N-th event
|
|
4175
|
+
* after the last event whose `headers.offset` is ≤ X. (When `X` is
|
|
4176
|
+
* `null`, the anchor is the stream start and the target is the N-th
|
|
4177
|
+
* event from the very beginning.) The returned position is the count
|
|
4178
|
+
* of events to KEEP — events 1..position survive the filter.
|
|
4179
|
+
*
|
|
4180
|
+
* A pointer is valid when:
|
|
4181
|
+
* - `pointer.offset` is `null` (stream start) OR matches some
|
|
4182
|
+
* event's `headers.offset` value, AND
|
|
4183
|
+
* - `pointer.subOffset` is in `[1, total events past the anchor]`.
|
|
4184
|
+
*/
|
|
4185
|
+
resolveForkPointerTarget(events, pointer, streamPath) {
|
|
4186
|
+
let positionAtAnchor = 0;
|
|
4187
|
+
let anchorSeen = pointer.offset === null;
|
|
4188
|
+
for (const event of events) {
|
|
4189
|
+
const headers = isRecord(event.headers) ? event.headers : void 0;
|
|
4190
|
+
const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
|
|
4191
|
+
if (eventOffset === void 0) continue;
|
|
4192
|
+
if (pointer.offset === null) continue;
|
|
4193
|
+
if (eventOffset === pointer.offset) anchorSeen = true;
|
|
4194
|
+
if (eventOffset <= pointer.offset) positionAtAnchor++;
|
|
4195
|
+
}
|
|
4196
|
+
if (!anchorSeen) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.offset (${pointer.offset ?? `<stream-start>`}) does not match any event's Stream-Next-Offset on ${streamPath}`, 400);
|
|
4197
|
+
const eventsPastAnchor = events.length - positionAtAnchor;
|
|
4198
|
+
if (pointer.subOffset < 1 || pointer.subOffset > eventsPastAnchor) throw new ElectricAgentsError(ErrCodeInvalidRequest, `fork_pointer.sub_offset ${pointer.subOffset} out of range past anchor on ${streamPath} (valid: 1..${eventsPastAnchor})`, 400);
|
|
4199
|
+
return positionAtAnchor + pointer.subOffset;
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
4203
|
+
* applied at the root. After filtering the root's manifest at the fork
|
|
4204
|
+
* pointer, only children whose manifest entries landed at or before the
|
|
4205
|
+
* pointer remain; those kept children carry their CURRENT (HEAD) subtree
|
|
4206
|
+
* along with them. Children dropped from the root's manifest, and any
|
|
4207
|
+
* of their descendants, are excluded.
|
|
4208
|
+
*/
|
|
4209
|
+
computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
|
|
4210
|
+
const keptChildUrls = new Set();
|
|
4211
|
+
for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
|
|
4212
|
+
const childrenByParent = new Map();
|
|
4213
|
+
for (const entity of sourceTree) {
|
|
4214
|
+
if (!entity.parent) continue;
|
|
4215
|
+
const list = childrenByParent.get(entity.parent) ?? [];
|
|
4216
|
+
list.push(entity);
|
|
4217
|
+
childrenByParent.set(entity.parent, list);
|
|
4218
|
+
}
|
|
4219
|
+
const rootEntity = sourceTree.find((e) => e.url === rootUrl);
|
|
4220
|
+
if (!rootEntity) return [];
|
|
4221
|
+
const result = [rootEntity];
|
|
4222
|
+
const queue = [];
|
|
4223
|
+
for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
|
|
4224
|
+
const seen = new Set([rootUrl]);
|
|
4225
|
+
while (queue.length > 0) {
|
|
4226
|
+
const entity = queue.shift();
|
|
4227
|
+
if (seen.has(entity.url)) continue;
|
|
4228
|
+
seen.add(entity.url);
|
|
4229
|
+
result.push(entity);
|
|
4230
|
+
for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
|
|
4231
|
+
}
|
|
4232
|
+
return result;
|
|
4233
|
+
}
|
|
3235
4234
|
async listEntitySubtree(root) {
|
|
3236
4235
|
const result = [];
|
|
3237
4236
|
const queue = [root];
|
|
@@ -3348,7 +4347,6 @@ var EntityManager = class {
|
|
|
3348
4347
|
for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
3349
4348
|
stringMap.set(sourceUrl, forkUrl);
|
|
3350
4349
|
stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`);
|
|
3351
|
-
stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`);
|
|
3352
4350
|
}
|
|
3353
4351
|
for (const [sourceId, forkId] of sharedStateIdMap) {
|
|
3354
4352
|
stringMap.set(sourceId, forkId);
|
|
@@ -3356,7 +4354,7 @@ var EntityManager = class {
|
|
|
3356
4354
|
}
|
|
3357
4355
|
return stringMap;
|
|
3358
4356
|
}
|
|
3359
|
-
buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap) {
|
|
4357
|
+
buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap, createdBy) {
|
|
3360
4358
|
const now = Date.now();
|
|
3361
4359
|
return entitiesToFork.map((source) => {
|
|
3362
4360
|
const forkUrl = entityUrlMap.get(source.url);
|
|
@@ -3369,14 +4367,12 @@ var EntityManager = class {
|
|
|
3369
4367
|
url: forkUrl,
|
|
3370
4368
|
type,
|
|
3371
4369
|
status: `idle`,
|
|
3372
|
-
streams: {
|
|
3373
|
-
main: `${forkUrl}/main`,
|
|
3374
|
-
error: `${forkUrl}/error`
|
|
3375
|
-
},
|
|
4370
|
+
streams: { main: `${forkUrl}/main` },
|
|
3376
4371
|
subscription_id: `${type}-handler`,
|
|
3377
4372
|
write_token: randomUUID(),
|
|
3378
4373
|
spawn_args: spawnArgs,
|
|
3379
4374
|
parent,
|
|
4375
|
+
created_by: createdBy ?? source.created_by,
|
|
3380
4376
|
created_at: now,
|
|
3381
4377
|
updated_at: now
|
|
3382
4378
|
};
|
|
@@ -3610,7 +4606,7 @@ var EntityManager = class {
|
|
|
3610
4606
|
}
|
|
3611
4607
|
async materializeForkManifestSideEffects(entityUrl, manifests) {
|
|
3612
4608
|
for (const [manifestKey, manifest] of manifests) {
|
|
3613
|
-
await this.
|
|
4609
|
+
await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest);
|
|
3614
4610
|
const wake = buildManifestWakeRegistration(entityUrl, manifest, manifestKey);
|
|
3615
4611
|
if (wake) await this.wakeRegistry.register({
|
|
3616
4612
|
...wake,
|
|
@@ -3640,6 +4636,7 @@ var EntityManager = class {
|
|
|
3640
4636
|
await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
|
|
3641
4637
|
entityUrl: targetUrl,
|
|
3642
4638
|
from: senderUrl,
|
|
4639
|
+
from_agent: senderUrl,
|
|
3643
4640
|
payload: manifest.payload,
|
|
3644
4641
|
key: `scheduled-${producerId}`,
|
|
3645
4642
|
type: typeof manifest.messageType === `string` ? manifest.messageType : void 0,
|
|
@@ -3679,12 +4676,14 @@ var EntityManager = class {
|
|
|
3679
4676
|
const now = new Date().toISOString();
|
|
3680
4677
|
const key = req.key ?? `msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3681
4678
|
const value = {
|
|
3682
|
-
from: req.from,
|
|
4679
|
+
from: req.from_principal ?? req.from,
|
|
3683
4680
|
payload: req.payload,
|
|
3684
4681
|
timestamp: now,
|
|
3685
4682
|
mode: req.mode ?? `immediate`,
|
|
3686
4683
|
status: req.mode === `queued` || req.mode === `paused` ? `pending` : `processed`
|
|
3687
4684
|
};
|
|
4685
|
+
if (req.from_principal) value.from_principal = req.from_principal;
|
|
4686
|
+
if (req.from_agent) value.from_agent = req.from_agent;
|
|
3688
4687
|
if (req.type) value.message_type = req.type;
|
|
3689
4688
|
if (req.position) value.position = req.position;
|
|
3690
4689
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
@@ -3856,9 +4855,9 @@ var EntityManager = class {
|
|
|
3856
4855
|
if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
3857
4856
|
return updated;
|
|
3858
4857
|
}
|
|
3859
|
-
async ensureEntitiesMembershipStream(tags) {
|
|
4858
|
+
async ensureEntitiesMembershipStream(tags, principal) {
|
|
3860
4859
|
if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
|
|
3861
|
-
return this.entityBridgeManager.register(this.validateTags(tags));
|
|
4860
|
+
return this.entityBridgeManager.register(this.validateTags(tags), principal.url, principal.kind);
|
|
3862
4861
|
}
|
|
3863
4862
|
async writeManifestEntry(entityUrl, key, operation, value, opts) {
|
|
3864
4863
|
const entity = await this.registry.getEntity(entityUrl);
|
|
@@ -3876,11 +4875,11 @@ var EntityManager = class {
|
|
|
3876
4875
|
const encoded = this.encodeChangeEvent(event);
|
|
3877
4876
|
if (opts?.producerId) {
|
|
3878
4877
|
await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
|
|
3879
|
-
await this.
|
|
4878
|
+
await this.syncManifestLinks(entityUrl, key, operation, value);
|
|
3880
4879
|
return;
|
|
3881
4880
|
}
|
|
3882
4881
|
await this.streamClient.append(entity.streams.main, encoded);
|
|
3883
|
-
await this.
|
|
4882
|
+
await this.syncManifestLinks(entityUrl, key, operation, value);
|
|
3884
4883
|
}
|
|
3885
4884
|
async upsertCronSchedule(entityUrl, req) {
|
|
3886
4885
|
if (req.payload === void 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Missing required field: payload`, 400);
|
|
@@ -4029,6 +5028,8 @@ var EntityManager = class {
|
|
|
4029
5028
|
await this.scheduler.enqueueDelayedSend({
|
|
4030
5029
|
entityUrl,
|
|
4031
5030
|
from: req.from,
|
|
5031
|
+
from_principal: req.from_principal,
|
|
5032
|
+
from_agent: req.from_agent,
|
|
4032
5033
|
payload: req.payload,
|
|
4033
5034
|
key: req.key,
|
|
4034
5035
|
type: req.type,
|
|
@@ -4071,14 +5072,23 @@ var EntityManager = class {
|
|
|
4071
5072
|
await this.streamClient.appendIdempotent(subscriber.streams.main, this.encodeChangeEvent(wakeEvent), { producerId: `wake-reg-${result.registrationDbId}-${result.sourceEventKey}` });
|
|
4072
5073
|
});
|
|
4073
5074
|
}
|
|
4074
|
-
async
|
|
5075
|
+
async syncManifestLinks(entityUrl, manifestKey, operation, value) {
|
|
4075
5076
|
const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
|
|
4076
5077
|
await this.registry.replaceEntityManifestSource(entityUrl, manifestKey, sourceRef);
|
|
5078
|
+
const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
|
|
5079
|
+
await this.registry.replaceSharedStateLink(entityUrl, manifestKey, sharedStateId);
|
|
4077
5080
|
}
|
|
4078
5081
|
extractEntitiesSourceRef(manifest) {
|
|
4079
5082
|
if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
4080
5083
|
return void 0;
|
|
4081
5084
|
}
|
|
5085
|
+
extractSharedStateId(manifest) {
|
|
5086
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
|
|
5087
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
|
|
5088
|
+
if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
5089
|
+
const config = isRecord(manifest.config) ? manifest.config : void 0;
|
|
5090
|
+
return typeof config?.id === `string` ? config.id : void 0;
|
|
5091
|
+
}
|
|
4082
5092
|
/**
|
|
4083
5093
|
* Read a child entity's stream and extract concatenated text deltas
|
|
4084
5094
|
* for a specific run, plus any error messages for that run.
|
|
@@ -4242,14 +5252,7 @@ var EntityManager = class {
|
|
|
4242
5252
|
await this.streamClient.append(entity.streams.main, signalData);
|
|
4243
5253
|
return;
|
|
4244
5254
|
}
|
|
4245
|
-
const
|
|
4246
|
-
type: `signal`,
|
|
4247
|
-
key: signalEvent.key,
|
|
4248
|
-
value: signalEvent.value,
|
|
4249
|
-
headers: signalEvent.headers
|
|
4250
|
-
};
|
|
4251
|
-
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4252
|
-
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
5255
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData]]) try {
|
|
4253
5256
|
await this.streamClient.append(streamPath, data, { close: true });
|
|
4254
5257
|
} catch (err) {
|
|
4255
5258
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -4339,6 +5342,7 @@ var EntityManager = class {
|
|
|
4339
5342
|
streams: entity.streams,
|
|
4340
5343
|
tags: entity.tags,
|
|
4341
5344
|
spawnArgs: entity.spawn_args,
|
|
5345
|
+
sandbox: entity.sandbox,
|
|
4342
5346
|
createdBy: entity.created_by
|
|
4343
5347
|
},
|
|
4344
5348
|
principal: principalFromCreatedBy(entity.created_by),
|
|
@@ -4624,12 +5628,35 @@ const wakeConditionSchema = Type.Union([Type.Literal(`runFinished`), Type.Object
|
|
|
4624
5628
|
Type.Literal(`delete`)
|
|
4625
5629
|
])))
|
|
4626
5630
|
})]);
|
|
5631
|
+
const permissionSubjectSchema = Type.Object({
|
|
5632
|
+
subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
|
|
5633
|
+
subject_value: Type.String()
|
|
5634
|
+
}, { additionalProperties: false });
|
|
5635
|
+
const entityPermissionSchema = Type.Union([
|
|
5636
|
+
Type.Literal(`read`),
|
|
5637
|
+
Type.Literal(`write`),
|
|
5638
|
+
Type.Literal(`delete`),
|
|
5639
|
+
Type.Literal(`signal`),
|
|
5640
|
+
Type.Literal(`fork`),
|
|
5641
|
+
Type.Literal(`schedule`),
|
|
5642
|
+
Type.Literal(`spawn`),
|
|
5643
|
+
Type.Literal(`manage`)
|
|
5644
|
+
]);
|
|
5645
|
+
const entityPermissionGrantInputSchema = Type.Object({
|
|
5646
|
+
...permissionSubjectSchema.properties,
|
|
5647
|
+
permission: entityPermissionSchema,
|
|
5648
|
+
propagation: Type.Optional(Type.Union([Type.Literal(`self`), Type.Literal(`descendants`)])),
|
|
5649
|
+
copy_to_children: Type.Optional(Type.Boolean()),
|
|
5650
|
+
expires_at: Type.Optional(Type.String())
|
|
5651
|
+
}, { additionalProperties: false });
|
|
4627
5652
|
const spawnBodySchema = Type.Object({
|
|
4628
5653
|
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
4629
5654
|
tags: Type.Optional(stringRecordSchema$1),
|
|
4630
5655
|
parent: Type.Optional(Type.String()),
|
|
4631
5656
|
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
5657
|
+
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
4632
5658
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
5659
|
+
grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
|
|
4633
5660
|
wake: Type.Optional(Type.Object({
|
|
4634
5661
|
subscriberUrl: Type.String(),
|
|
4635
5662
|
condition: wakeConditionSchema,
|
|
@@ -4651,8 +5678,22 @@ const sendBodySchema = Type.Object({
|
|
|
4651
5678
|
])),
|
|
4652
5679
|
position: Type.Optional(Type.String()),
|
|
4653
5680
|
afterMs: Type.Optional(Type.Number()),
|
|
4654
|
-
from: Type.Optional(Type.String())
|
|
5681
|
+
from: Type.Optional(Type.String()),
|
|
5682
|
+
from_principal: Type.Optional(Type.String()),
|
|
5683
|
+
from_agent: Type.Optional(Type.String())
|
|
4655
5684
|
});
|
|
5685
|
+
function agentUrlForPrincipal(principal) {
|
|
5686
|
+
if (principal.kind === `agent`) return `/${principal.id}`;
|
|
5687
|
+
if (principal.key.startsWith(`entity:`)) return `/${principal.key.slice(`entity:`.length)}`;
|
|
5688
|
+
return null;
|
|
5689
|
+
}
|
|
5690
|
+
function agentUrlPath(value) {
|
|
5691
|
+
try {
|
|
5692
|
+
return new URL(value).pathname;
|
|
5693
|
+
} catch {
|
|
5694
|
+
return value;
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
4656
5697
|
const inboxMessageBodySchema = Type.Object({
|
|
4657
5698
|
payload: Type.Optional(Type.Unknown()),
|
|
4658
5699
|
position: Type.Optional(Type.String()),
|
|
@@ -4670,7 +5711,11 @@ const inboxMessageBodySchema = Type.Object({
|
|
|
4670
5711
|
});
|
|
4671
5712
|
const forkBodySchema = Type.Object({
|
|
4672
5713
|
instance_id: Type.Optional(Type.String()),
|
|
4673
|
-
waitTimeoutMs: Type.Optional(Type.Number())
|
|
5714
|
+
waitTimeoutMs: Type.Optional(Type.Number()),
|
|
5715
|
+
fork_pointer: Type.Optional(Type.Object({
|
|
5716
|
+
offset: Type.Union([Type.String(), Type.Null()]),
|
|
5717
|
+
sub_offset: Type.Number()
|
|
5718
|
+
}))
|
|
4674
5719
|
});
|
|
4675
5720
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
4676
5721
|
const entitySignalSchema = Type.Union([
|
|
@@ -4727,24 +5772,27 @@ const attachmentSubjectTypes = new Set([
|
|
|
4727
5772
|
]);
|
|
4728
5773
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
4729
5774
|
entitiesRouter.get(`/`, listEntities);
|
|
4730
|
-
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
|
|
4731
|
-
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
4732
|
-
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
4733
|
-
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
4734
|
-
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
4735
|
-
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
4736
|
-
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
|
|
4737
|
-
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
|
|
4738
|
-
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
|
|
4739
|
-
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
4740
|
-
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
4741
|
-
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
|
|
4742
|
-
entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), setTag);
|
|
4743
|
-
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, deleteTag);
|
|
4744
|
-
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
|
|
4745
|
-
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
|
|
4746
|
-
entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
|
|
4747
|
-
entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
|
|
5775
|
+
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), withSpawnPermission, spawnEntity);
|
|
5776
|
+
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), getEntity);
|
|
5777
|
+
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), headEntity);
|
|
5778
|
+
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
|
|
5779
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
|
|
5780
|
+
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
|
|
5781
|
+
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
|
|
5782
|
+
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
|
|
5783
|
+
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
|
|
5784
|
+
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), withEntityPermission(`write`), updateInboxMessage);
|
|
5785
|
+
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withEntityPermission(`write`), deleteInboxMessage);
|
|
5786
|
+
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), withEntityPermission(`fork`), forkEntity);
|
|
5787
|
+
entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), withEntityPermission(`write`), setTag);
|
|
5788
|
+
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
|
|
5789
|
+
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
|
|
5790
|
+
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
|
|
5791
|
+
entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
|
|
5792
|
+
entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
|
|
5793
|
+
entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
|
|
5794
|
+
entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
|
|
5795
|
+
entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
|
|
4748
5796
|
function entityUrlFromSegments(type, instanceId) {
|
|
4749
5797
|
if (!type || !instanceId) return null;
|
|
4750
5798
|
if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
|
|
@@ -4843,6 +5891,17 @@ function rejectPrincipalEntityMutation(request, action) {
|
|
|
4843
5891
|
if (entity.type !== `principal`) return void 0;
|
|
4844
5892
|
return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be ${action}`);
|
|
4845
5893
|
}
|
|
5894
|
+
function parseExpiresAt$1(value) {
|
|
5895
|
+
if (value === void 0) return void 0;
|
|
5896
|
+
const expiresAt = new Date(value);
|
|
5897
|
+
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
5898
|
+
return expiresAt;
|
|
5899
|
+
}
|
|
5900
|
+
function parseGrantId$1(request) {
|
|
5901
|
+
const grantId = Number.parseInt(String(request.params.grantId), 10);
|
|
5902
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
|
|
5903
|
+
return grantId;
|
|
5904
|
+
}
|
|
4846
5905
|
async function withExistingEntity(request, ctx) {
|
|
4847
5906
|
const entityUrl = entityUrlFromSegments(request.params.type, request.params.instanceId);
|
|
4848
5907
|
if (!entityUrl) return void 0;
|
|
@@ -4873,17 +5932,76 @@ async function withSpawnableEntityType(request, ctx) {
|
|
|
4873
5932
|
if (request.params.type === `principal`) return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be spawned directly`);
|
|
4874
5933
|
const entityType = await ctx.entityManager.registry.getEntityType(request.params.type);
|
|
4875
5934
|
if (!entityType) return apiError(404, ErrCodeUnknownEntityType, `Entity type "${request.params.type}" not found`);
|
|
5935
|
+
request.spawnRoute = { entityType };
|
|
4876
5936
|
return void 0;
|
|
4877
5937
|
}
|
|
5938
|
+
function withEntityPermission(permission) {
|
|
5939
|
+
return async (request, ctx) => {
|
|
5940
|
+
const { entity } = requireExistingEntityRoute(request);
|
|
5941
|
+
if (await canAccessEntity(ctx, entity, permission, request)) return void 0;
|
|
5942
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to ${permission} ${entity.url}`);
|
|
5943
|
+
};
|
|
5944
|
+
}
|
|
5945
|
+
async function withSpawnPermission(request, ctx) {
|
|
5946
|
+
const parsed = routeBody(request);
|
|
5947
|
+
const entityType = request.spawnRoute?.entityType;
|
|
5948
|
+
if (!entityType) throw new Error(`spawnable entity type middleware did not run`);
|
|
5949
|
+
if (!await canAccessEntityType(ctx, entityType, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
|
|
5950
|
+
if (!parsed.parent) return void 0;
|
|
5951
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
|
|
5952
|
+
if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
|
|
5953
|
+
if (await canAccessEntity(ctx, parent, `spawn`, request)) return await validateParentedSpawnGrants(request, ctx, parent, parsed);
|
|
5954
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
|
|
5955
|
+
}
|
|
5956
|
+
async function validateParentedSpawnGrants(request, ctx, parent, parsed) {
|
|
5957
|
+
const needsParentManage = (parsed.grants ?? []).some(requiresParentManageForInitialGrant);
|
|
5958
|
+
if (!needsParentManage) return void 0;
|
|
5959
|
+
if (await canAccessEntity(ctx, parent, `manage`, request)) return void 0;
|
|
5960
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to delegate broad grants from ${parent.url}`);
|
|
5961
|
+
}
|
|
5962
|
+
function requiresParentManageForInitialGrant(grant) {
|
|
5963
|
+
return grant.permission === `manage` || grant.subject_kind === `principal_kind` || grant.propagation === `descendants` || grant.copy_to_children === true;
|
|
5964
|
+
}
|
|
4878
5965
|
async function listEntities({ query }, ctx) {
|
|
4879
5966
|
const { entities: entities$1 } = await ctx.entityManager.registry.listEntities({
|
|
4880
5967
|
type: firstQueryValue$1(query.type),
|
|
4881
5968
|
status: firstQueryValue$1(query.status),
|
|
4882
5969
|
parent: firstQueryValue$1(query.parent),
|
|
4883
|
-
created_by: firstQueryValue$1(query.created_by)
|
|
5970
|
+
created_by: firstQueryValue$1(query.created_by),
|
|
5971
|
+
readableBy: {
|
|
5972
|
+
...principalSubject(ctx.principal),
|
|
5973
|
+
bypass: isPermissionBypassPrincipal(ctx)
|
|
5974
|
+
}
|
|
4884
5975
|
});
|
|
4885
5976
|
return json(entities$1.map((entity) => toPublicEntity(entity)));
|
|
4886
5977
|
}
|
|
5978
|
+
async function listEntityPermissionGrants(request, ctx) {
|
|
5979
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5980
|
+
const grants = await ctx.entityManager.registry.listEntityPermissionGrants(entityUrl);
|
|
5981
|
+
return json({ grants });
|
|
5982
|
+
}
|
|
5983
|
+
async function createEntityPermissionGrant(request, ctx) {
|
|
5984
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5985
|
+
const parsed = routeBody(request);
|
|
5986
|
+
const grant = await ctx.entityManager.registry.createEntityPermissionGrant({
|
|
5987
|
+
entityUrl,
|
|
5988
|
+
permission: parsed.permission,
|
|
5989
|
+
subjectKind: parsed.subject_kind,
|
|
5990
|
+
subjectValue: parsed.subject_value,
|
|
5991
|
+
propagation: parsed.propagation,
|
|
5992
|
+
copyToChildren: parsed.copy_to_children,
|
|
5993
|
+
expiresAt: parseExpiresAt$1(parsed.expires_at),
|
|
5994
|
+
createdBy: ctx.principal.url
|
|
5995
|
+
});
|
|
5996
|
+
await ctx.entityBridgeManager.onEntityChanged(entityUrl);
|
|
5997
|
+
return json(grant, { status: 201 });
|
|
5998
|
+
}
|
|
5999
|
+
async function deleteEntityPermissionGrant(request, ctx) {
|
|
6000
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
6001
|
+
const deleted = await ctx.entityManager.registry.deleteEntityPermissionGrant(entityUrl, parseGrantId$1(request));
|
|
6002
|
+
if (deleted) await ctx.entityBridgeManager.onEntityChanged(entityUrl);
|
|
6003
|
+
return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
|
|
6004
|
+
}
|
|
4887
6005
|
async function upsertSchedule(request, ctx) {
|
|
4888
6006
|
const principalMutationError = rejectPrincipalEntityMutation(request, `scheduled`);
|
|
4889
6007
|
if (principalMutationError) return principalMutationError;
|
|
@@ -4988,7 +6106,12 @@ async function forkEntity(request, ctx) {
|
|
|
4988
6106
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
|
|
4989
6107
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
4990
6108
|
rootInstanceId: parsed.instance_id,
|
|
4991
|
-
waitTimeoutMs: parsed.waitTimeoutMs
|
|
6109
|
+
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
6110
|
+
createdBy: ctx.principal.url,
|
|
6111
|
+
...parsed.fork_pointer && { forkPointer: {
|
|
6112
|
+
offset: parsed.fork_pointer.offset,
|
|
6113
|
+
subOffset: parsed.fork_pointer.sub_offset
|
|
6114
|
+
} }
|
|
4992
6115
|
});
|
|
4993
6116
|
for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
|
|
4994
6117
|
return json({
|
|
@@ -5000,26 +6123,27 @@ async function sendEntity(request, ctx) {
|
|
|
5000
6123
|
const parsed = routeBody(request);
|
|
5001
6124
|
const principal = ctx.principal;
|
|
5002
6125
|
if (parsed.from !== void 0 && parsed.from !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from must match Electric-Principal`);
|
|
6126
|
+
if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
|
|
6127
|
+
if (parsed.from_agent !== void 0) {
|
|
6128
|
+
const principalAgentUrl = agentUrlForPrincipal(principal);
|
|
6129
|
+
if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
|
|
6130
|
+
}
|
|
5003
6131
|
await ctx.entityManager.ensurePrincipal(principal);
|
|
5004
6132
|
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
5005
6133
|
const dispatchEntity = entity.dispatch_policy ? entity : await backfillEntityDispatchPolicy(ctx, entity);
|
|
5006
6134
|
await linkEntityDispatchSubscription(ctx, dispatchEntity);
|
|
5007
|
-
|
|
5008
|
-
from: principal.url,
|
|
5009
|
-
payload: parsed.payload,
|
|
5010
|
-
key: parsed.key,
|
|
5011
|
-
type: parsed.type,
|
|
5012
|
-
mode: parsed.mode,
|
|
5013
|
-
position: parsed.position
|
|
5014
|
-
}, new Date(Date.now() + parsed.afterMs));
|
|
5015
|
-
else await ctx.entityManager.send(entityUrl, {
|
|
6135
|
+
const sendReq = {
|
|
5016
6136
|
from: principal.url,
|
|
6137
|
+
from_principal: principal.url,
|
|
6138
|
+
from_agent: parsed.from_agent,
|
|
5017
6139
|
payload: parsed.payload,
|
|
5018
6140
|
key: parsed.key,
|
|
5019
6141
|
type: parsed.type,
|
|
5020
6142
|
mode: parsed.mode,
|
|
5021
6143
|
position: parsed.position
|
|
5022
|
-
}
|
|
6144
|
+
};
|
|
6145
|
+
if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
|
|
6146
|
+
else await ctx.entityManager.send(entityUrl, sendReq);
|
|
5023
6147
|
return status(204);
|
|
5024
6148
|
}
|
|
5025
6149
|
async function createAttachment(request, ctx) {
|
|
@@ -5086,10 +6210,22 @@ async function spawnEntity(request, ctx) {
|
|
|
5086
6210
|
tags: parsed.tags,
|
|
5087
6211
|
parent: parsed.parent,
|
|
5088
6212
|
dispatch_policy: dispatchPolicy,
|
|
6213
|
+
sandbox: parsed.sandbox,
|
|
5089
6214
|
initialMessage: void 0,
|
|
5090
6215
|
wake: parsed.wake,
|
|
5091
6216
|
created_by: principal.url
|
|
5092
6217
|
});
|
|
6218
|
+
if (parsed.parent) await ctx.entityManager.registry.copyEntityPermissionGrantsForSpawn(parsed.parent, entity.url, principal.url);
|
|
6219
|
+
for (const grant of parsed.grants ?? []) await ctx.entityManager.registry.createEntityPermissionGrant({
|
|
6220
|
+
entityUrl: entity.url,
|
|
6221
|
+
permission: grant.permission,
|
|
6222
|
+
subjectKind: grant.subject_kind,
|
|
6223
|
+
subjectValue: grant.subject_value,
|
|
6224
|
+
propagation: grant.propagation,
|
|
6225
|
+
copyToChildren: grant.copy_to_children,
|
|
6226
|
+
expiresAt: parseExpiresAt$1(grant.expires_at),
|
|
6227
|
+
createdBy: principal.url
|
|
6228
|
+
});
|
|
5093
6229
|
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
5094
6230
|
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
5095
6231
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
@@ -5141,6 +6277,12 @@ async function signalEntity(request, ctx) {
|
|
|
5141
6277
|
//#region src/routing/entity-types-router.ts
|
|
5142
6278
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
|
|
5143
6279
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
|
|
6280
|
+
const typePermissionGrantInputSchema = Type.Object({
|
|
6281
|
+
subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
|
|
6282
|
+
subject_value: Type.String(),
|
|
6283
|
+
permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
|
|
6284
|
+
expires_at: Type.Optional(Type.String())
|
|
6285
|
+
}, { additionalProperties: false });
|
|
5144
6286
|
const registerEntityTypeBodySchema = Type.Object({
|
|
5145
6287
|
name: Type.Optional(Type.String()),
|
|
5146
6288
|
description: Type.Optional(Type.String()),
|
|
@@ -5148,7 +6290,8 @@ const registerEntityTypeBodySchema = Type.Object({
|
|
|
5148
6290
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
5149
6291
|
state_schemas: Type.Optional(schemaMapSchema),
|
|
5150
6292
|
serve_endpoint: Type.Optional(Type.String()),
|
|
5151
|
-
default_dispatch_policy: Type.Optional(dispatchPolicySchema)
|
|
6293
|
+
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
6294
|
+
permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
|
|
5152
6295
|
}, { additionalProperties: false });
|
|
5153
6296
|
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
5154
6297
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
@@ -5156,20 +6299,56 @@ const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
|
5156
6299
|
}, { additionalProperties: false });
|
|
5157
6300
|
const entityTypesRouter = Router({ base: `/_electric/entity-types` });
|
|
5158
6301
|
entityTypesRouter.get(`/`, listEntityTypes);
|
|
5159
|
-
entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), registerEntityType);
|
|
5160
|
-
entityTypesRouter.patch(`/:name/schemas`, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
|
|
5161
|
-
entityTypesRouter.get(`/:name`, getEntityType);
|
|
5162
|
-
entityTypesRouter.delete(`/:name`, deleteEntityType);
|
|
6302
|
+
entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), withEntityTypeRegistrationPermission, registerEntityType);
|
|
6303
|
+
entityTypesRouter.patch(`/:name/schemas`, withExistingEntityType, withEntityTypeManagePermission, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
|
|
6304
|
+
entityTypesRouter.get(`/:name`, withExistingEntityType, withEntityTypeSpawnPermission, getEntityType);
|
|
6305
|
+
entityTypesRouter.delete(`/:name`, withExistingEntityType, withEntityTypeManagePermission, deleteEntityType);
|
|
6306
|
+
entityTypesRouter.get(`/:name/grants`, withExistingEntityType, withEntityTypeManagePermission, listTypePermissionGrants);
|
|
6307
|
+
entityTypesRouter.post(`/:name/grants`, withExistingEntityType, withSchema(typePermissionGrantInputSchema), withEntityTypeManagePermission, createTypePermissionGrant);
|
|
6308
|
+
entityTypesRouter.delete(`/:name/grants/:grantId`, withExistingEntityType, withEntityTypeManagePermission, deleteTypePermissionGrant);
|
|
5163
6309
|
async function registerEntityType(request, ctx) {
|
|
5164
6310
|
const parsed = routeBody(request);
|
|
5165
6311
|
const normalized = normalizeEntityTypeRequest(parsed);
|
|
5166
6312
|
if (normalized.serve_endpoint && !normalized.description && !normalized.creation_schema) return await discoverServeEndpoint(ctx, normalized);
|
|
5167
6313
|
const entityType = await ctx.entityManager.registerEntityType(normalized);
|
|
6314
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, normalized);
|
|
5168
6315
|
return json(toPublicEntityType(entityType), { status: 201 });
|
|
5169
6316
|
}
|
|
5170
6317
|
async function listEntityTypes(_request, ctx) {
|
|
5171
6318
|
const entityTypes$1 = await ctx.entityManager.registry.listEntityTypes();
|
|
5172
|
-
|
|
6319
|
+
const visible = [];
|
|
6320
|
+
for (const entityType of entityTypes$1) if (await canAccessEntityType(ctx, entityType, `spawn`)) visible.push(entityType);
|
|
6321
|
+
return json(visible.map((entityType) => toPublicEntityType(entityType)));
|
|
6322
|
+
}
|
|
6323
|
+
async function withExistingEntityType(request, ctx) {
|
|
6324
|
+
const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
|
|
6325
|
+
if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
|
|
6326
|
+
request.entityTypeRoute = { entityType };
|
|
6327
|
+
return void 0;
|
|
6328
|
+
}
|
|
6329
|
+
async function withEntityTypeManagePermission(request, ctx) {
|
|
6330
|
+
const entityType = request.entityTypeRoute?.entityType;
|
|
6331
|
+
if (!entityType) throw new Error(`entity type middleware did not run`);
|
|
6332
|
+
if (await canAccessEntityType(ctx, entityType, `manage`, request)) return void 0;
|
|
6333
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${entityType.name}`);
|
|
6334
|
+
}
|
|
6335
|
+
async function withEntityTypeSpawnPermission(request, ctx) {
|
|
6336
|
+
const entityType = request.entityTypeRoute?.entityType;
|
|
6337
|
+
if (!entityType) throw new Error(`entity type middleware did not run`);
|
|
6338
|
+
if (await canAccessEntityType(ctx, entityType, `spawn`, request)) return void 0;
|
|
6339
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
|
|
6340
|
+
}
|
|
6341
|
+
async function withEntityTypeRegistrationPermission(request, ctx) {
|
|
6342
|
+
const parsed = normalizeEntityTypeRequest(routeBody(request));
|
|
6343
|
+
if (!parsed.name) return void 0;
|
|
6344
|
+
const existing = await ctx.entityManager.registry.getEntityType(parsed.name);
|
|
6345
|
+
if (existing) {
|
|
6346
|
+
request.entityTypeRoute = { entityType: existing };
|
|
6347
|
+
if (await canAccessEntityType(ctx, existing, `manage`, request)) return void 0;
|
|
6348
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${existing.name}`);
|
|
6349
|
+
}
|
|
6350
|
+
if (await canRegisterEntityType(ctx, parsed, request)) return void 0;
|
|
6351
|
+
return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to register entity types`);
|
|
5173
6352
|
}
|
|
5174
6353
|
async function discoverServeEndpoint(ctx, parsed) {
|
|
5175
6354
|
try {
|
|
@@ -5178,17 +6357,17 @@ async function discoverServeEndpoint(ctx, parsed) {
|
|
|
5178
6357
|
const manifest = await response.json();
|
|
5179
6358
|
if (manifest.name !== parsed.name) return apiError(400, ErrCodeServeEndpointNameMismatch, `Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`);
|
|
5180
6359
|
manifest.serve_endpoint = parsed.serve_endpoint;
|
|
6360
|
+
manifest.permission_grants = parsed.permission_grants;
|
|
5181
6361
|
const entityType = await ctx.entityManager.registerEntityType(normalizeEntityTypeRequest(manifest));
|
|
6362
|
+
await applyRegistrationPermissionGrants(ctx, entityType.name, manifest);
|
|
5182
6363
|
return json(toPublicEntityType(entityType), { status: 201 });
|
|
5183
6364
|
} catch (err) {
|
|
5184
6365
|
if (err instanceof ElectricAgentsError) throw err;
|
|
5185
6366
|
return apiError(502, ErrCodeServeEndpointUnreachable, `Failed to reach serve endpoint: ${err instanceof Error ? err.message : String(err)}`);
|
|
5186
6367
|
}
|
|
5187
6368
|
}
|
|
5188
|
-
async function getEntityType(request
|
|
5189
|
-
|
|
5190
|
-
if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
|
|
5191
|
-
return json(toPublicEntityType(entityType));
|
|
6369
|
+
async function getEntityType(request) {
|
|
6370
|
+
return json(toPublicEntityType(request.entityTypeRoute.entityType));
|
|
5192
6371
|
}
|
|
5193
6372
|
async function amendSchemas(request, ctx) {
|
|
5194
6373
|
const parsed = routeBody(request);
|
|
@@ -5202,6 +6381,47 @@ async function deleteEntityType(request, ctx) {
|
|
|
5202
6381
|
await ctx.entityManager.deleteEntityType(request.params.name);
|
|
5203
6382
|
return status(204);
|
|
5204
6383
|
}
|
|
6384
|
+
async function listTypePermissionGrants(request, ctx) {
|
|
6385
|
+
const grants = await ctx.entityManager.registry.listEntityTypePermissionGrants(request.entityTypeRoute.entityType.name);
|
|
6386
|
+
return json({ grants });
|
|
6387
|
+
}
|
|
6388
|
+
async function createTypePermissionGrant(request, ctx) {
|
|
6389
|
+
const parsed = routeBody(request);
|
|
6390
|
+
const grant = await ctx.entityManager.registry.createEntityTypePermissionGrant({
|
|
6391
|
+
entityType: request.entityTypeRoute.entityType.name,
|
|
6392
|
+
permission: parsed.permission,
|
|
6393
|
+
subjectKind: parsed.subject_kind,
|
|
6394
|
+
subjectValue: parsed.subject_value,
|
|
6395
|
+
expiresAt: parseExpiresAt(parsed.expires_at),
|
|
6396
|
+
createdBy: ctx.principal.url
|
|
6397
|
+
});
|
|
6398
|
+
return json(grant, { status: 201 });
|
|
6399
|
+
}
|
|
6400
|
+
async function deleteTypePermissionGrant(request, ctx) {
|
|
6401
|
+
const deleted = await ctx.entityManager.registry.deleteEntityTypePermissionGrant(request.entityTypeRoute.entityType.name, parseGrantId(request));
|
|
6402
|
+
return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
|
|
6403
|
+
}
|
|
6404
|
+
async function applyRegistrationPermissionGrants(ctx, entityType, request) {
|
|
6405
|
+
for (const grant of request.permission_grants ?? []) await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
|
|
6406
|
+
entityType,
|
|
6407
|
+
permission: grant.permission,
|
|
6408
|
+
subjectKind: grant.subject_kind,
|
|
6409
|
+
subjectValue: grant.subject_value,
|
|
6410
|
+
expiresAt: parseExpiresAt(grant.expires_at),
|
|
6411
|
+
createdBy: ctx.principal.url
|
|
6412
|
+
});
|
|
6413
|
+
}
|
|
6414
|
+
function parseGrantId(request) {
|
|
6415
|
+
const grantId = Number.parseInt(String(request.params.grantId), 10);
|
|
6416
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
|
|
6417
|
+
return grantId;
|
|
6418
|
+
}
|
|
6419
|
+
function parseExpiresAt(value) {
|
|
6420
|
+
if (value === void 0) return void 0;
|
|
6421
|
+
const expiresAt = new Date(value);
|
|
6422
|
+
if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
|
|
6423
|
+
return expiresAt;
|
|
6424
|
+
}
|
|
5205
6425
|
function normalizeEntityTypeRequest(parsed) {
|
|
5206
6426
|
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
|
|
5207
6427
|
return {
|
|
@@ -5214,7 +6434,8 @@ function normalizeEntityTypeRequest(parsed) {
|
|
|
5214
6434
|
default_dispatch_policy: parsed.default_dispatch_policy ?? (serveEndpoint ? { targets: [{
|
|
5215
6435
|
type: `webhook`,
|
|
5216
6436
|
url: serveEndpoint
|
|
5217
|
-
}] } : void 0)
|
|
6437
|
+
}] } : void 0),
|
|
6438
|
+
permission_grants: parsed.permission_grants
|
|
5218
6439
|
};
|
|
5219
6440
|
}
|
|
5220
6441
|
function toPublicEntityType(entityType) {
|
|
@@ -5273,6 +6494,7 @@ function applyCors(response) {
|
|
|
5273
6494
|
`content-type`,
|
|
5274
6495
|
`authorization`,
|
|
5275
6496
|
`electric-claim-token`,
|
|
6497
|
+
`electric-owner-entity`,
|
|
5276
6498
|
ELECTRIC_PRINCIPAL_HEADER,
|
|
5277
6499
|
`ngrok-skip-browser-warning`
|
|
5278
6500
|
].join(`, `));
|
|
@@ -5323,7 +6545,7 @@ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMemb
|
|
|
5323
6545
|
observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
|
|
5324
6546
|
async function ensureEntitiesMembershipStream(request, ctx) {
|
|
5325
6547
|
const parsed = routeBody(request);
|
|
5326
|
-
const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {});
|
|
6548
|
+
const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
|
|
5327
6549
|
return json(result);
|
|
5328
6550
|
}
|
|
5329
6551
|
async function ensureCronStream(request, ctx) {
|
|
@@ -5340,6 +6562,12 @@ function withLeadingSlash(path$1) {
|
|
|
5340
6562
|
|
|
5341
6563
|
//#endregion
|
|
5342
6564
|
//#region src/routing/runners-router.ts
|
|
6565
|
+
const sandboxProfileBodySchema = Type.Object({
|
|
6566
|
+
name: Type.String(),
|
|
6567
|
+
label: Type.String(),
|
|
6568
|
+
description: Type.Optional(Type.String()),
|
|
6569
|
+
remote: Type.Optional(Type.Boolean())
|
|
6570
|
+
});
|
|
5343
6571
|
const registerRunnerBodySchema = Type.Object({
|
|
5344
6572
|
id: Type.String(),
|
|
5345
6573
|
owner_principal: Type.Optional(Type.String()),
|
|
@@ -5352,7 +6580,8 @@ const registerRunnerBodySchema = Type.Object({
|
|
|
5352
6580
|
Type.Literal(`server`)
|
|
5353
6581
|
])),
|
|
5354
6582
|
admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
|
|
5355
|
-
wake_stream: Type.Optional(Type.String())
|
|
6583
|
+
wake_stream: Type.Optional(Type.String()),
|
|
6584
|
+
sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
|
|
5356
6585
|
});
|
|
5357
6586
|
const heartbeatBodySchema = Type.Object({
|
|
5358
6587
|
lease_ms: Type.Optional(Type.Number()),
|
|
@@ -5450,7 +6679,8 @@ async function registerRunner(request, ctx) {
|
|
|
5450
6679
|
label: parsed.label,
|
|
5451
6680
|
kind: parsed.kind,
|
|
5452
6681
|
adminStatus: parsed.admin_status,
|
|
5453
|
-
wakeStream: parsed.wake_stream
|
|
6682
|
+
wakeStream: parsed.wake_stream,
|
|
6683
|
+
sandboxProfiles: parsed.sandbox_profiles
|
|
5454
6684
|
});
|
|
5455
6685
|
await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
|
|
5456
6686
|
return json(runner, { status: 201 });
|
|
@@ -5680,6 +6910,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
5680
6910
|
streams: entity.streams,
|
|
5681
6911
|
tags: entity.tags,
|
|
5682
6912
|
spawnArgs: entity.spawn_args,
|
|
6913
|
+
sandbox: entity.sandbox,
|
|
5683
6914
|
createdBy: entity.created_by
|
|
5684
6915
|
},
|
|
5685
6916
|
principal: principalFromCreatedBy(entity.created_by)
|
|
@@ -6193,16 +7424,31 @@ function buildTagsWhereClause(tags) {
|
|
|
6193
7424
|
function sqlStringLiteral$1(value) {
|
|
6194
7425
|
return `'${value.replace(/'/g, `''`)}'`;
|
|
6195
7426
|
}
|
|
6196
|
-
function buildTenantTagsWhereClause(tenantId, tags) {
|
|
6197
|
-
|
|
7427
|
+
function buildTenantTagsWhereClause(tenantId, tags, principalUrl$1, principalKind, permissionBypass) {
|
|
7428
|
+
const readableWhere = principalUrl$1 && principalKind ? buildReadableEntitiesWhere({
|
|
7429
|
+
tenantId,
|
|
7430
|
+
principalUrl: principalUrl$1,
|
|
7431
|
+
principalKind,
|
|
7432
|
+
permissionBypass
|
|
7433
|
+
}) : `tenant_id = ${sqlStringLiteral$1(tenantId)} AND FALSE`;
|
|
7434
|
+
return `${readableWhere} AND (${buildTagsWhereClause(tags)})`;
|
|
6198
7435
|
}
|
|
6199
7436
|
function shapeEntityKey(message) {
|
|
6200
7437
|
return message.value.url;
|
|
6201
7438
|
}
|
|
7439
|
+
function principalScopedSourceRef(tagSourceRef, principalUrl$1, principalKind) {
|
|
7440
|
+
return `${tagSourceRef}-${hashString(JSON.stringify({
|
|
7441
|
+
principalKind,
|
|
7442
|
+
principalUrl: principalUrl$1
|
|
7443
|
+
}))}`;
|
|
7444
|
+
}
|
|
6202
7445
|
var EntityBridge = class {
|
|
6203
7446
|
sourceRef;
|
|
6204
7447
|
tags;
|
|
6205
7448
|
streamUrl;
|
|
7449
|
+
principalUrl;
|
|
7450
|
+
principalKind;
|
|
7451
|
+
permissionBypass;
|
|
6206
7452
|
currentMembers = new Map();
|
|
6207
7453
|
producer = null;
|
|
6208
7454
|
liveAbortController = null;
|
|
@@ -6219,6 +7465,9 @@ var EntityBridge = class {
|
|
|
6219
7465
|
this.sourceRef = row.sourceRef;
|
|
6220
7466
|
this.tags = normalizeTags(row.tags);
|
|
6221
7467
|
this.streamUrl = row.streamUrl;
|
|
7468
|
+
this.principalUrl = row.principalUrl;
|
|
7469
|
+
this.principalKind = row.principalKind;
|
|
7470
|
+
this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl);
|
|
6222
7471
|
this.initialShapeHandle = row.shapeHandle;
|
|
6223
7472
|
this.initialShapeOffset = row.shapeOffset;
|
|
6224
7473
|
}
|
|
@@ -6326,7 +7575,7 @@ var EntityBridge = class {
|
|
|
6326
7575
|
url: electricUrlWithPath(this.electricUrl, `/v1/shape`).toString(),
|
|
6327
7576
|
params: {
|
|
6328
7577
|
table: `entities`,
|
|
6329
|
-
where: buildTenantTagsWhereClause(this.tenantId, this.tags),
|
|
7578
|
+
where: buildTenantTagsWhereClause(this.tenantId, this.tags, this.principalUrl, this.principalKind, this.permissionBypass),
|
|
6330
7579
|
...this.electricSecret ? { secret: this.electricSecret } : {},
|
|
6331
7580
|
columns: [...ENTITY_SHAPE_COLUMNS],
|
|
6332
7581
|
replica: `full`
|
|
@@ -6489,15 +7738,17 @@ var EntityBridgeManager = class {
|
|
|
6489
7738
|
await bridge.stop();
|
|
6490
7739
|
}));
|
|
6491
7740
|
}
|
|
6492
|
-
async register(tagsInput) {
|
|
7741
|
+
async register(tagsInput, principalUrl$1, principalKind) {
|
|
6493
7742
|
if (!this.electricUrl) throw new Error(`[entity-bridge] Electric URL is required for entities()`);
|
|
6494
7743
|
const tags = normalizeTags(assertTags(tagsInput));
|
|
6495
|
-
const sourceRef = sourceRefForTags(tags);
|
|
7744
|
+
const sourceRef = principalScopedSourceRef(sourceRefForTags(tags), principalUrl$1, principalKind);
|
|
6496
7745
|
const streamUrl = getEntitiesStreamPath(sourceRef);
|
|
6497
7746
|
const row = await this.registry.upsertEntityBridge({
|
|
6498
7747
|
sourceRef,
|
|
6499
7748
|
tags,
|
|
6500
|
-
streamUrl
|
|
7749
|
+
streamUrl,
|
|
7750
|
+
principalUrl: principalUrl$1,
|
|
7751
|
+
principalKind
|
|
6501
7752
|
});
|
|
6502
7753
|
await this.registry.touchEntityBridge(sourceRef);
|
|
6503
7754
|
await this.ensureBridge(row);
|
|
@@ -7317,6 +8568,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
7317
8568
|
try {
|
|
7318
8569
|
await this.manager.send(payload.entityUrl, {
|
|
7319
8570
|
from: payload.from,
|
|
8571
|
+
from_principal: payload.from_principal,
|
|
8572
|
+
from_agent: payload.from_agent,
|
|
7320
8573
|
payload: payload.payload,
|
|
7321
8574
|
key: payload.key ?? `scheduled-task-${taskId}`,
|
|
7322
8575
|
type: payload.type
|
|
@@ -7389,6 +8642,7 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
7389
8642
|
await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
|
|
7390
8643
|
entityUrl: targetUrl,
|
|
7391
8644
|
from: senderUrl,
|
|
8645
|
+
from_agent: senderUrl,
|
|
7392
8646
|
payload: value.payload,
|
|
7393
8647
|
key: `scheduled-${producerId}`,
|
|
7394
8648
|
type: typeof value.messageType === `string` ? value.messageType : void 0,
|
|
@@ -7413,11 +8667,20 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
7413
8667
|
async applyManifestEntitySource(ownerEntityUrl, manifestKey, operation, value) {
|
|
7414
8668
|
const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
|
|
7415
8669
|
await this.manager.registry.replaceEntityManifestSource(ownerEntityUrl, manifestKey, sourceRef);
|
|
8670
|
+
const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
|
|
8671
|
+
await this.manager.registry.replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId);
|
|
7416
8672
|
}
|
|
7417
8673
|
extractEntitiesSourceRef(manifest) {
|
|
7418
8674
|
if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
7419
8675
|
return void 0;
|
|
7420
8676
|
}
|
|
8677
|
+
extractSharedStateId(manifest) {
|
|
8678
|
+
if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
|
|
8679
|
+
if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
|
|
8680
|
+
if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
|
|
8681
|
+
const config = typeof manifest.config === `object` && manifest.config !== null && !Array.isArray(manifest.config) ? manifest.config : void 0;
|
|
8682
|
+
return typeof config?.id === `string` ? config.id : void 0;
|
|
8683
|
+
}
|
|
7421
8684
|
async maybeMarkEntityIdleAfterRunFinished(entityUrl) {
|
|
7422
8685
|
const primaryStream = `${entityUrl}/main`;
|
|
7423
8686
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
@@ -8090,11 +9353,21 @@ var WakeRegistry = class {
|
|
|
8090
9353
|
}
|
|
8091
9354
|
const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
|
|
8092
9355
|
if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
|
|
8093
|
-
|
|
9356
|
+
const change = {
|
|
8094
9357
|
collection: eventType,
|
|
8095
9358
|
kind,
|
|
8096
9359
|
key: event.key || ``
|
|
8097
|
-
}
|
|
9360
|
+
};
|
|
9361
|
+
if (eventType === `inbox`) {
|
|
9362
|
+
const value = event.value;
|
|
9363
|
+
if (typeof value?.from === `string`) change.from = value.from;
|
|
9364
|
+
if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
|
|
9365
|
+
if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
|
|
9366
|
+
if (`payload` in (value ?? {})) change.payload = value?.payload;
|
|
9367
|
+
if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
|
|
9368
|
+
if (typeof value?.message_type === `string`) change.message_type = value.message_type;
|
|
9369
|
+
}
|
|
9370
|
+
return { change };
|
|
8098
9371
|
}
|
|
8099
9372
|
};
|
|
8100
9373
|
|
|
@@ -8440,6 +9713,7 @@ var ElectricAgentsServer = class {
|
|
|
8440
9713
|
entityBridgeManager: this.entityBridgeManager,
|
|
8441
9714
|
...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
|
|
8442
9715
|
...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
|
|
9716
|
+
...this.options.authorizeRequest ? { authorizeRequest: this.options.authorizeRequest } : {},
|
|
8443
9717
|
isShuttingDown: () => this.shuttingDown,
|
|
8444
9718
|
mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0
|
|
8445
9719
|
};
|