@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.
@@ -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) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
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
- applyTenantShapeWhere(target, options.tenantId);
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
- applyTenantShapeWhere(target, options.tenantId);
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
- applyTenantShapeWhere(target, options.tenantId);
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
- applyTenantShapeWhere(target, options.tenantId);
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
- applyTenantShapeWhere(target, options.tenantId);
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
- const tenantWhere = [`tenant_id = ${sqlStringLiteral$2(tenantId)}`, ...extraConditions].join(` AND `);
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 ? `${tenantWhere} AND (${existingWhere})` : tenantWhere);
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) return 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) return 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, errorStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
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
- status: `fulfilled`,
3018
- value: void 0
3019
- },
3020
- {
3021
- status: `fulfilled`,
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, errorStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
3898
+ const [mainStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
3031
3899
  contentType,
3032
3900
  body: initialBody
3033
- }), this.streamClient.create(errorPath, { contentType })]);
3034
- return [
3035
- mainStreamResult$1,
3036
- errorStreamResult$1,
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` || errorStreamResult.status === `rejected` || entityResult.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 : errorStreamResult.status === `rejected` ? errorStreamResult.reason : null;
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 : errorStreamResult.status === `rejected` ? errorStreamResult.reason : entityResult.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
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
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
- const snapshot = await this.readForkStateSnapshot(sourceTree);
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(sourceTree, {
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(sourceTree, entityUrlMap, stringMap);
3106
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
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
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
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.syncEntitiesManifestSource(entityUrl, manifestKey, `upsert`, manifest);
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.syncEntitiesManifestSource(entityUrl, key, operation, value);
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.syncEntitiesManifestSource(entityUrl, key, operation, value);
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 syncEntitiesManifestSource(entityUrl, manifestKey, operation, value) {
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 errorCloseEvent = {
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
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, {
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
- return json(entityTypes$1.map((entityType) => toPublicEntityType(entityType)));
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, ctx) {
5189
- const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
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
- return `tenant_id = ${sqlStringLiteral$1(tenantId)} AND (${buildTagsWhereClause(tags)})`;
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
- return { change: {
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
  };