@electric-ax/agents-server 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -41,8 +41,8 @@ const __electric_ax_agents_runtime = __toESM(require("@electric-ax/agents-runtim
41
41
  const __durable_streams_client = __toESM(require("@durable-streams/client"));
42
42
  const __electric_sql_client = __toESM(require("@electric-sql/client"));
43
43
  const pino = __toESM(require("pino"));
44
- const fastq = __toESM(require("fastq"));
45
44
  const __sinclair_typebox = __toESM(require("@sinclair/typebox"));
45
+ const fastq = __toESM(require("fastq"));
46
46
  const ajv = __toESM(require("ajv"));
47
47
  const __opentelemetry_api = __toESM(require("@opentelemetry/api"));
48
48
  const itty_router = __toESM(require("itty-router"));
@@ -55,11 +55,16 @@ __export(schema_exports, {
55
55
  entities: () => entities,
56
56
  entityBridges: () => entityBridges,
57
57
  entityDispatchState: () => entityDispatchState,
58
+ entityEffectivePermissions: () => entityEffectivePermissions,
59
+ entityLineage: () => entityLineage,
58
60
  entityManifestSources: () => entityManifestSources,
61
+ entityPermissionGrants: () => entityPermissionGrants,
62
+ entityTypePermissionGrants: () => entityTypePermissionGrants,
59
63
  entityTypes: () => entityTypes,
60
64
  runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
61
65
  runners: () => runners,
62
66
  scheduledTasks: () => scheduledTasks,
67
+ sharedStateLinks: () => sharedStateLinks,
63
68
  subscriptionWebhooks: () => subscriptionWebhooks,
64
69
  tagStreamOutbox: () => tagStreamOutbox,
65
70
  users: () => users,
@@ -90,6 +95,7 @@ const entities = (0, drizzle_orm_pg_core.pgTable)(`entities`, {
90
95
  tags: (0, drizzle_orm_pg_core.jsonb)(`tags`).notNull().default({}),
91
96
  tagsIndex: (0, drizzle_orm_pg_core.text)(`tags_index`).array().notNull().default(drizzle_orm.sql`'{}'::text[]`),
92
97
  spawnArgs: (0, drizzle_orm_pg_core.jsonb)(`spawn_args`).default({}),
98
+ sandbox: (0, drizzle_orm_pg_core.jsonb)(`sandbox`),
93
99
  parent: (0, drizzle_orm_pg_core.text)(`parent`),
94
100
  createdBy: (0, drizzle_orm_pg_core.text)(`created_by`),
95
101
  typeRevision: (0, drizzle_orm_pg_core.integer)(`type_revision`),
@@ -106,6 +112,94 @@ const entities = (0, drizzle_orm_pg_core.pgTable)(`entities`, {
106
112
  (0, drizzle_orm_pg_core.index)(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
107
113
  (0, drizzle_orm_pg_core.check)(`chk_entities_status`, drizzle_orm.sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
108
114
  ]);
115
+ const entityTypePermissionGrants = (0, drizzle_orm_pg_core.pgTable)(`entity_type_permission_grants`, {
116
+ id: (0, drizzle_orm_pg_core.bigserial)(`id`, { mode: `number` }).primaryKey(),
117
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
118
+ entityType: (0, drizzle_orm_pg_core.text)(`entity_type`).notNull(),
119
+ permission: (0, drizzle_orm_pg_core.text)(`permission`).notNull(),
120
+ subjectKind: (0, drizzle_orm_pg_core.text)(`subject_kind`).notNull(),
121
+ subjectValue: (0, drizzle_orm_pg_core.text)(`subject_value`).notNull(),
122
+ createdBy: (0, drizzle_orm_pg_core.text)(`created_by`),
123
+ expiresAt: (0, drizzle_orm_pg_core.timestamp)(`expires_at`, { withTimezone: true }),
124
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
125
+ updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
126
+ }, (table) => [
127
+ (0, drizzle_orm_pg_core.index)(`idx_type_permission_grants_lookup`).on(table.tenantId, table.entityType, table.permission, table.subjectKind, table.subjectValue),
128
+ (0, drizzle_orm_pg_core.index)(`idx_type_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
129
+ (0, drizzle_orm_pg_core.check)(`chk_type_permission_grants_permission`, drizzle_orm.sql`${table.permission} IN ('spawn', 'manage')`),
130
+ (0, drizzle_orm_pg_core.check)(`chk_type_permission_grants_subject_kind`, drizzle_orm.sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
131
+ ]);
132
+ const entityLineage = (0, drizzle_orm_pg_core.pgTable)(`entity_lineage`, {
133
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
134
+ ancestorUrl: (0, drizzle_orm_pg_core.text)(`ancestor_url`).notNull(),
135
+ descendantUrl: (0, drizzle_orm_pg_core.text)(`descendant_url`).notNull(),
136
+ depth: (0, drizzle_orm_pg_core.integer)(`depth`).notNull(),
137
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow()
138
+ }, (table) => [
139
+ (0, drizzle_orm_pg_core.primaryKey)({ columns: [
140
+ table.tenantId,
141
+ table.ancestorUrl,
142
+ table.descendantUrl
143
+ ] }),
144
+ (0, drizzle_orm_pg_core.index)(`idx_entity_lineage_descendant`).on(table.tenantId, table.descendantUrl),
145
+ (0, drizzle_orm_pg_core.check)(`chk_entity_lineage_depth`, drizzle_orm.sql`${table.depth} >= 0`)
146
+ ]);
147
+ const entityPermissionGrants = (0, drizzle_orm_pg_core.pgTable)(`entity_permission_grants`, {
148
+ id: (0, drizzle_orm_pg_core.bigserial)(`id`, { mode: `number` }).primaryKey(),
149
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
150
+ entityUrl: (0, drizzle_orm_pg_core.text)(`entity_url`).notNull(),
151
+ permission: (0, drizzle_orm_pg_core.text)(`permission`).notNull(),
152
+ subjectKind: (0, drizzle_orm_pg_core.text)(`subject_kind`).notNull(),
153
+ subjectValue: (0, drizzle_orm_pg_core.text)(`subject_value`).notNull(),
154
+ propagation: (0, drizzle_orm_pg_core.text)(`propagation`).notNull().default(`self`),
155
+ copyToChildren: (0, drizzle_orm_pg_core.boolean)(`copy_to_children`).notNull().default(false),
156
+ createdBy: (0, drizzle_orm_pg_core.text)(`created_by`),
157
+ expiresAt: (0, drizzle_orm_pg_core.timestamp)(`expires_at`, { withTimezone: true }),
158
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
159
+ updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
160
+ }, (table) => [
161
+ (0, drizzle_orm_pg_core.index)(`idx_entity_permission_grants_entity`).on(table.tenantId, table.entityUrl),
162
+ (0, drizzle_orm_pg_core.index)(`idx_entity_permission_grants_subject`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue),
163
+ (0, drizzle_orm_pg_core.index)(`idx_entity_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
164
+ (0, drizzle_orm_pg_core.check)(`chk_entity_permission_grants_permission`, drizzle_orm.sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
165
+ (0, drizzle_orm_pg_core.check)(`chk_entity_permission_grants_subject_kind`, drizzle_orm.sql`${table.subjectKind} IN ('principal', 'principal_kind')`),
166
+ (0, drizzle_orm_pg_core.check)(`chk_entity_permission_grants_propagation`, drizzle_orm.sql`${table.propagation} IN ('self', 'descendants')`)
167
+ ]);
168
+ const entityEffectivePermissions = (0, drizzle_orm_pg_core.pgTable)(`entity_effective_permissions`, {
169
+ id: (0, drizzle_orm_pg_core.bigserial)(`id`, { mode: `number` }).primaryKey(),
170
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
171
+ entityUrl: (0, drizzle_orm_pg_core.text)(`entity_url`).notNull(),
172
+ sourceEntityUrl: (0, drizzle_orm_pg_core.text)(`source_entity_url`).notNull(),
173
+ sourceGrantId: (0, drizzle_orm_pg_core.bigint)(`source_grant_id`, { mode: `number` }).notNull(),
174
+ permission: (0, drizzle_orm_pg_core.text)(`permission`).notNull(),
175
+ subjectKind: (0, drizzle_orm_pg_core.text)(`subject_kind`).notNull(),
176
+ subjectValue: (0, drizzle_orm_pg_core.text)(`subject_value`).notNull(),
177
+ expiresAt: (0, drizzle_orm_pg_core.timestamp)(`expires_at`, { withTimezone: true }),
178
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow()
179
+ }, (table) => [
180
+ (0, drizzle_orm_pg_core.unique)(`uq_entity_effective_permission`).on(table.tenantId, table.entityUrl, table.sourceGrantId),
181
+ (0, drizzle_orm_pg_core.index)(`idx_entity_effective_permissions_lookup`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue, table.entityUrl),
182
+ (0, drizzle_orm_pg_core.index)(`idx_entity_effective_permissions_entity`).on(table.tenantId, table.entityUrl),
183
+ (0, drizzle_orm_pg_core.index)(`idx_entity_effective_permissions_expiry`).on(table.tenantId, table.expiresAt),
184
+ (0, drizzle_orm_pg_core.check)(`chk_entity_effective_permissions_permission`, drizzle_orm.sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
185
+ (0, drizzle_orm_pg_core.check)(`chk_entity_effective_permissions_subject_kind`, drizzle_orm.sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
186
+ ]);
187
+ const sharedStateLinks = (0, drizzle_orm_pg_core.pgTable)(`shared_state_links`, {
188
+ tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
189
+ sharedStateId: (0, drizzle_orm_pg_core.text)(`shared_state_id`).notNull(),
190
+ ownerEntityUrl: (0, drizzle_orm_pg_core.text)(`owner_entity_url`).notNull(),
191
+ manifestKey: (0, drizzle_orm_pg_core.text)(`manifest_key`).notNull(),
192
+ createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
193
+ updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
194
+ }, (table) => [
195
+ (0, drizzle_orm_pg_core.primaryKey)({ columns: [
196
+ table.tenantId,
197
+ table.ownerEntityUrl,
198
+ table.manifestKey
199
+ ] }),
200
+ (0, drizzle_orm_pg_core.index)(`idx_shared_state_links_shared_state`).on(table.tenantId, table.sharedStateId),
201
+ (0, drizzle_orm_pg_core.index)(`idx_shared_state_links_owner`).on(table.tenantId, table.ownerEntityUrl)
202
+ ]);
109
203
  const users = (0, drizzle_orm_pg_core.pgTable)(`users`, {
110
204
  tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
111
205
  id: (0, drizzle_orm_pg_core.text)(`id`).notNull(),
@@ -131,6 +225,7 @@ const runners = (0, drizzle_orm_pg_core.pgTable)(`runners`, {
131
225
  kind: (0, drizzle_orm_pg_core.text)(`kind`).notNull().default(`local`),
132
226
  adminStatus: (0, drizzle_orm_pg_core.text)(`admin_status`).notNull().default(`enabled`),
133
227
  wakeStream: (0, drizzle_orm_pg_core.text)(`wake_stream`).notNull(),
228
+ sandboxProfiles: (0, drizzle_orm_pg_core.jsonb)(`sandbox_profiles`).notNull().default([]),
134
229
  createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
135
230
  updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
136
231
  }, (table) => [
@@ -291,12 +386,18 @@ const entityBridges = (0, drizzle_orm_pg_core.pgTable)(`entity_bridges`, {
291
386
  sourceRef: (0, drizzle_orm_pg_core.text)(`source_ref`).notNull(),
292
387
  tags: (0, drizzle_orm_pg_core.jsonb)(`tags`).notNull(),
293
388
  streamUrl: (0, drizzle_orm_pg_core.text)(`stream_url`).notNull(),
389
+ principalUrl: (0, drizzle_orm_pg_core.text)(`principal_url`),
390
+ principalKind: (0, drizzle_orm_pg_core.text)(`principal_kind`),
294
391
  shapeHandle: (0, drizzle_orm_pg_core.text)(`shape_handle`),
295
392
  shapeOffset: (0, drizzle_orm_pg_core.text)(`shape_offset`),
296
393
  lastObserverActivityAt: (0, drizzle_orm_pg_core.timestamp)(`last_observer_activity_at`, { withTimezone: true }).notNull().defaultNow(),
297
394
  createdAt: (0, drizzle_orm_pg_core.timestamp)(`created_at`, { withTimezone: true }).notNull().defaultNow(),
298
395
  updatedAt: (0, drizzle_orm_pg_core.timestamp)(`updated_at`, { withTimezone: true }).notNull().defaultNow()
299
- }, (table) => [(0, drizzle_orm_pg_core.primaryKey)({ columns: [table.tenantId, table.sourceRef] }), (0, drizzle_orm_pg_core.unique)(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
396
+ }, (table) => [
397
+ (0, drizzle_orm_pg_core.primaryKey)({ columns: [table.tenantId, table.sourceRef] }),
398
+ (0, drizzle_orm_pg_core.unique)(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl),
399
+ (0, drizzle_orm_pg_core.index)(`idx_entity_bridges_principal`).on(table.tenantId, table.principalKind, table.principalUrl)
400
+ ]);
300
401
  const entityManifestSources = (0, drizzle_orm_pg_core.pgTable)(`entity_manifest_sources`, {
301
402
  tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
302
403
  ownerEntityUrl: (0, drizzle_orm_pg_core.text)(`owner_entity_url`).notNull(),
@@ -428,6 +529,7 @@ function toPublicEntity(entity) {
428
529
  dispatch_policy: entity.dispatch_policy,
429
530
  tags: entity.tags,
430
531
  spawn_args: entity.spawn_args,
532
+ sandbox: entity.sandbox,
431
533
  parent: entity.parent,
432
534
  created_by: entity.created_by,
433
535
  created_at: entity.created_at,
@@ -483,19 +585,35 @@ function isDuplicateUrlError(err) {
483
585
  return e.code === `23505`;
484
586
  }
485
587
  const DEFAULT_RUNNER_LEASE_MS = 3e4;
588
+ const PERMISSION_PRUNE_INTERVAL_MS = 3e4;
486
589
  function runnerWakeStream(runnerId) {
487
590
  return `/runners/${runnerId}/wake`;
488
591
  }
489
592
  var PostgresRegistry = class {
593
+ lastPermissionPruneStartedAt = 0;
594
+ permissionPrunePromise = null;
490
595
  constructor(db, tenantId = DEFAULT_TENANT_ID) {
491
596
  this.db = db;
492
597
  this.tenantId = tenantId;
493
598
  }
494
599
  async initialize() {}
495
600
  close() {}
601
+ async ensureUserForPrincipal(principal) {
602
+ if (principal.kind !== `user`) return;
603
+ await this.db.insert(users).values({
604
+ tenantId: this.tenantId,
605
+ id: principal.id
606
+ }).onConflictDoNothing();
607
+ }
496
608
  async createRunner(input) {
497
609
  const now = new Date();
498
610
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
611
+ const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
612
+ name: p.name,
613
+ label: p.label,
614
+ ...p.description !== void 0 && { description: p.description },
615
+ ...p.remote !== void 0 && { remote: p.remote }
616
+ })) : void 0;
499
617
  await this.db.insert(runners).values({
500
618
  tenantId: this.tenantId,
501
619
  id: input.id,
@@ -504,6 +622,7 @@ var PostgresRegistry = class {
504
622
  kind: input.kind ?? `local`,
505
623
  adminStatus: input.adminStatus ?? `enabled`,
506
624
  wakeStream,
625
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
507
626
  updatedAt: now
508
627
  }).onConflictDoUpdate({
509
628
  target: [runners.tenantId, runners.id],
@@ -513,6 +632,7 @@ var PostgresRegistry = class {
513
632
  kind: input.kind ?? `local`,
514
633
  adminStatus: input.adminStatus ?? `enabled`,
515
634
  wakeStream,
635
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
516
636
  updatedAt: now
517
637
  }
518
638
  });
@@ -520,6 +640,30 @@ var PostgresRegistry = class {
520
640
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
521
641
  return runner;
522
642
  }
643
+ /**
644
+ * Every sandbox profile advertised by a runner in this tenant (one entry
645
+ * per runner that advertises it — names may repeat across runners). Used by
646
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
647
+ * is remote (so a shared sandbox can skip the single-runner guard).
648
+ */
649
+ async listSandboxProfiles() {
650
+ const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where((0, drizzle_orm.eq)(runners.tenantId, this.tenantId));
651
+ const profiles = [];
652
+ for (const row of rows) {
653
+ const list = row.sandboxProfiles;
654
+ if (!Array.isArray(list)) continue;
655
+ for (const entry of list) {
656
+ if (!entry || typeof entry.name !== `string`) continue;
657
+ profiles.push({
658
+ name: entry.name,
659
+ label: typeof entry.label === `string` ? entry.label : entry.name,
660
+ ...typeof entry.description === `string` && { description: entry.description },
661
+ ...typeof entry.remote === `boolean` && { remote: entry.remote }
662
+ });
663
+ }
664
+ }
665
+ return profiles;
666
+ }
523
667
  async getRunner(id) {
524
668
  const rows = await this.db.select().from(runners).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(runners.tenantId, this.tenantId), (0, drizzle_orm.eq)(runners.id, id))).limit(1);
525
669
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -780,6 +924,7 @@ var PostgresRegistry = class {
780
924
  tags: (0, __electric_ax_agents_runtime.normalizeTags)(entity.tags),
781
925
  tagsIndex: (0, __electric_ax_agents_runtime.buildTagsIndex)(entity.tags),
782
926
  spawnArgs: entity.spawn_args ?? {},
927
+ sandbox: entity.sandbox ?? null,
783
928
  parent: entity.parent ?? null,
784
929
  createdBy: entity.created_by ?? null,
785
930
  typeRevision: entity.type_revision ?? null,
@@ -794,6 +939,59 @@ var PostgresRegistry = class {
794
939
  pendingSourceStreams: [],
795
940
  updatedAt: new Date()
796
941
  }).onConflictDoNothing();
942
+ await tx.insert(entityLineage).values({
943
+ tenantId: this.tenantId,
944
+ ancestorUrl: entity.url,
945
+ descendantUrl: entity.url,
946
+ depth: 0
947
+ }).onConflictDoNothing();
948
+ if (entity.parent) await tx.execute(drizzle_orm.sql`
949
+ INSERT INTO ${entityLineage} (
950
+ tenant_id,
951
+ ancestor_url,
952
+ descendant_url,
953
+ depth
954
+ )
955
+ SELECT
956
+ ${this.tenantId},
957
+ ancestor_url,
958
+ ${entity.url},
959
+ depth + 1
960
+ FROM ${entityLineage}
961
+ WHERE tenant_id = ${this.tenantId}
962
+ AND descendant_url = ${entity.parent}
963
+ ON CONFLICT DO NOTHING
964
+ `);
965
+ await tx.execute(drizzle_orm.sql`
966
+ INSERT INTO ${entityEffectivePermissions} (
967
+ tenant_id,
968
+ entity_url,
969
+ source_entity_url,
970
+ source_grant_id,
971
+ permission,
972
+ subject_kind,
973
+ subject_value,
974
+ expires_at
975
+ )
976
+ SELECT
977
+ ${this.tenantId},
978
+ ${entity.url},
979
+ grants.entity_url,
980
+ grants.id,
981
+ grants.permission,
982
+ grants.subject_kind,
983
+ grants.subject_value,
984
+ grants.expires_at
985
+ FROM ${entityPermissionGrants} grants
986
+ JOIN ${entityLineage} lineage
987
+ ON lineage.tenant_id = grants.tenant_id
988
+ AND lineage.ancestor_url = grants.entity_url
989
+ AND lineage.descendant_url = ${entity.url}
990
+ WHERE grants.tenant_id = ${this.tenantId}
991
+ AND grants.propagation = 'descendants'
992
+ AND (grants.expires_at IS NULL OR grants.expires_at > now())
993
+ ON CONFLICT DO NOTHING
994
+ `);
797
995
  return parseInt(result[0].txid);
798
996
  });
799
997
  } catch (err) {
@@ -815,10 +1013,8 @@ var PostgresRegistry = class {
815
1013
  }
816
1014
  async getEntityByStream(streamPath) {
817
1015
  const mainSuffix = `/main`;
818
- const errorSuffix = `/error`;
819
1016
  let entityUrl = null;
820
1017
  if (streamPath.endsWith(mainSuffix)) entityUrl = streamPath.slice(0, -mainSuffix.length);
821
- else if (streamPath.endsWith(errorSuffix)) entityUrl = streamPath.slice(0, -errorSuffix.length);
822
1018
  if (!entityUrl) return null;
823
1019
  return this.getEntity(entityUrl);
824
1020
  }
@@ -828,6 +1024,23 @@ var PostgresRegistry = class {
828
1024
  if (filter?.status) conditions.push((0, drizzle_orm.eq)(entities.status, filter.status));
829
1025
  if (filter?.parent) conditions.push((0, drizzle_orm.eq)(entities.parent, filter.parent));
830
1026
  if (filter?.created_by) conditions.push((0, drizzle_orm.eq)(entities.createdBy, filter.created_by));
1027
+ if (filter?.readableBy && !filter.readableBy.bypass) conditions.push(drizzle_orm.sql`(
1028
+ ${entities.createdBy} = ${filter.readableBy.principalUrl}
1029
+ OR ${entities.url} IN (
1030
+ SELECT ${entityEffectivePermissions.entityUrl}
1031
+ FROM ${entityEffectivePermissions}
1032
+ WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
1033
+ AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
1034
+ AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
1035
+ AND (
1036
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1037
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
1038
+ OR
1039
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1040
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
1041
+ )
1042
+ )
1043
+ )`);
831
1044
  const whereClause = (0, drizzle_orm.and)(...conditions);
832
1045
  const countResult = await this.db.select({ count: drizzle_orm.sql`count(*)` }).from(entities).where(whereClause);
833
1046
  const total = Number(countResult[0].count);
@@ -840,6 +1053,189 @@ var PostgresRegistry = class {
840
1053
  total
841
1054
  };
842
1055
  }
1056
+ async createEntityTypePermissionGrant(input) {
1057
+ const [row] = await this.db.insert(entityTypePermissionGrants).values({
1058
+ tenantId: this.tenantId,
1059
+ entityType: input.entityType,
1060
+ permission: input.permission,
1061
+ subjectKind: input.subjectKind,
1062
+ subjectValue: input.subjectValue,
1063
+ createdBy: input.createdBy ?? null,
1064
+ expiresAt: input.expiresAt ?? null
1065
+ }).returning();
1066
+ return this.rowToEntityTypePermissionGrant(row);
1067
+ }
1068
+ async ensureEntityTypePermissionGrant(input) {
1069
+ const [existing] = await this.db.select().from(entityTypePermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityTypePermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityTypePermissionGrants.entityType, input.entityType), (0, drizzle_orm.eq)(entityTypePermissionGrants.permission, input.permission), (0, drizzle_orm.eq)(entityTypePermissionGrants.subjectKind, input.subjectKind), (0, drizzle_orm.eq)(entityTypePermissionGrants.subjectValue, input.subjectValue), input.expiresAt ? (0, drizzle_orm.eq)(entityTypePermissionGrants.expiresAt, input.expiresAt) : drizzle_orm.sql`${entityTypePermissionGrants.expiresAt} IS NULL`)).limit(1);
1070
+ if (existing) return this.rowToEntityTypePermissionGrant(existing);
1071
+ return await this.createEntityTypePermissionGrant(input);
1072
+ }
1073
+ async listEntityTypePermissionGrants(entityType) {
1074
+ const rows = await this.db.select().from(entityTypePermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityTypePermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityTypePermissionGrants.entityType, entityType))).orderBy(entityTypePermissionGrants.id);
1075
+ return rows.map((row) => this.rowToEntityTypePermissionGrant(row));
1076
+ }
1077
+ async deleteEntityTypePermissionGrant(entityType, grantId) {
1078
+ const rows = await this.db.delete(entityTypePermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityTypePermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityTypePermissionGrants.entityType, entityType), (0, drizzle_orm.eq)(entityTypePermissionGrants.id, grantId))).returning({ id: entityTypePermissionGrants.id });
1079
+ return rows.length > 0;
1080
+ }
1081
+ async hasEntityTypePermission(entityType, permission, subject) {
1082
+ const permissions = [permission, `manage`];
1083
+ const rows = await this.db.select({ id: entityTypePermissionGrants.id }).from(entityTypePermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityTypePermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityTypePermissionGrants.entityType, entityType), (0, drizzle_orm.inArray)(entityTypePermissionGrants.permission, [...permissions]), drizzle_orm.sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`, drizzle_orm.sql`(
1084
+ (${entityTypePermissionGrants.subjectKind} = 'principal'
1085
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
1086
+ OR
1087
+ (${entityTypePermissionGrants.subjectKind} = 'principal_kind'
1088
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
1089
+ )`)).limit(1);
1090
+ return rows.length > 0;
1091
+ }
1092
+ async createEntityPermissionGrant(input) {
1093
+ return await this.db.transaction(async (tx) => {
1094
+ const [row] = await tx.insert(entityPermissionGrants).values({
1095
+ tenantId: this.tenantId,
1096
+ entityUrl: input.entityUrl,
1097
+ permission: input.permission,
1098
+ subjectKind: input.subjectKind,
1099
+ subjectValue: input.subjectValue,
1100
+ propagation: input.propagation ?? `self`,
1101
+ copyToChildren: input.copyToChildren ?? false,
1102
+ createdBy: input.createdBy ?? null,
1103
+ expiresAt: input.expiresAt ?? null
1104
+ }).returning();
1105
+ await this.materializeEntityPermissionGrant(tx, row);
1106
+ return this.rowToEntityPermissionGrant(row);
1107
+ });
1108
+ }
1109
+ async listEntityPermissionGrants(entityUrl) {
1110
+ const rows = await this.db.select().from(entityPermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityPermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityPermissionGrants.entityUrl, entityUrl))).orderBy(entityPermissionGrants.id);
1111
+ return rows.map((row) => this.rowToEntityPermissionGrant(row));
1112
+ }
1113
+ async deleteEntityPermissionGrant(entityUrl, grantId) {
1114
+ return await this.db.transaction(async (tx) => {
1115
+ await tx.delete(entityEffectivePermissions).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityEffectivePermissions.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityEffectivePermissions.sourceGrantId, grantId)));
1116
+ const rows = await tx.delete(entityPermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityPermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityPermissionGrants.entityUrl, entityUrl), (0, drizzle_orm.eq)(entityPermissionGrants.id, grantId))).returning({ id: entityPermissionGrants.id });
1117
+ return rows.length > 0;
1118
+ });
1119
+ }
1120
+ async copyEntityPermissionGrantsForSpawn(parentEntityUrl, childEntityUrl, createdBy) {
1121
+ const parentGrants = await this.db.select().from(entityPermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityPermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityPermissionGrants.entityUrl, parentEntityUrl), (0, drizzle_orm.eq)(entityPermissionGrants.copyToChildren, true), drizzle_orm.sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`));
1122
+ const copied = [];
1123
+ for (const grant of parentGrants) copied.push(await this.createEntityPermissionGrant({
1124
+ entityUrl: childEntityUrl,
1125
+ permission: grant.permission,
1126
+ subjectKind: grant.subjectKind,
1127
+ subjectValue: grant.subjectValue,
1128
+ propagation: `self`,
1129
+ copyToChildren: grant.copyToChildren,
1130
+ createdBy,
1131
+ expiresAt: grant.expiresAt ?? void 0
1132
+ }));
1133
+ return copied;
1134
+ }
1135
+ async hasEntityPermission(entityUrl, permission, subject) {
1136
+ const permissions = [permission, `manage`];
1137
+ const rows = await this.db.select({ id: entityEffectivePermissions.id }).from(entityEffectivePermissions).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityEffectivePermissions.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityEffectivePermissions.entityUrl, entityUrl), (0, drizzle_orm.inArray)(entityEffectivePermissions.permission, [...permissions]), drizzle_orm.sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`, drizzle_orm.sql`(
1138
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1139
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
1140
+ OR
1141
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1142
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
1143
+ )`)).limit(1);
1144
+ return rows.length > 0;
1145
+ }
1146
+ async replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId) {
1147
+ await this.db.delete(sharedStateLinks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(sharedStateLinks.tenantId, this.tenantId), (0, drizzle_orm.eq)(sharedStateLinks.ownerEntityUrl, ownerEntityUrl), (0, drizzle_orm.eq)(sharedStateLinks.manifestKey, manifestKey)));
1148
+ if (!sharedStateId) return;
1149
+ await this.db.insert(sharedStateLinks).values({
1150
+ tenantId: this.tenantId,
1151
+ ownerEntityUrl,
1152
+ manifestKey,
1153
+ sharedStateId
1154
+ }).onConflictDoUpdate({
1155
+ target: [
1156
+ sharedStateLinks.tenantId,
1157
+ sharedStateLinks.ownerEntityUrl,
1158
+ sharedStateLinks.manifestKey
1159
+ ],
1160
+ set: {
1161
+ sharedStateId,
1162
+ updatedAt: new Date()
1163
+ }
1164
+ });
1165
+ }
1166
+ async listSharedStateLinkedEntityUrls(sharedStateId) {
1167
+ const rows = await this.db.selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl }).from(sharedStateLinks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(sharedStateLinks.tenantId, this.tenantId), (0, drizzle_orm.eq)(sharedStateLinks.sharedStateId, sharedStateId)));
1168
+ return rows.map((row) => row.ownerEntityUrl);
1169
+ }
1170
+ async pruneExpiredPermissionGrants(now = new Date(), options = {}) {
1171
+ if (this.permissionPrunePromise) return await this.permissionPrunePromise;
1172
+ const startedAt = Date.now();
1173
+ if (!options.force && startedAt - this.lastPermissionPruneStartedAt < PERMISSION_PRUNE_INTERVAL_MS) return;
1174
+ this.lastPermissionPruneStartedAt = startedAt;
1175
+ const promise = this.pruneExpiredPermissionGrantsNow(now);
1176
+ this.permissionPrunePromise = promise;
1177
+ try {
1178
+ await promise;
1179
+ } catch (error) {
1180
+ this.lastPermissionPruneStartedAt = 0;
1181
+ throw error;
1182
+ } finally {
1183
+ if (this.permissionPrunePromise === promise) this.permissionPrunePromise = null;
1184
+ }
1185
+ }
1186
+ async pruneExpiredPermissionGrantsNow(now) {
1187
+ await this.db.transaction(async (tx) => {
1188
+ const expiredEntityGrantIds = await tx.select({ id: entityPermissionGrants.id }).from(entityPermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityPermissionGrants.tenantId, this.tenantId), drizzle_orm.sql`${entityPermissionGrants.expiresAt} IS NOT NULL`, (0, drizzle_orm.lt)(entityPermissionGrants.expiresAt, now)));
1189
+ const ids = expiredEntityGrantIds.map((row) => row.id);
1190
+ if (ids.length > 0) {
1191
+ await tx.delete(entityEffectivePermissions).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityEffectivePermissions.tenantId, this.tenantId), (0, drizzle_orm.inArray)(entityEffectivePermissions.sourceGrantId, ids)));
1192
+ await tx.delete(entityPermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityPermissionGrants.tenantId, this.tenantId), (0, drizzle_orm.inArray)(entityPermissionGrants.id, ids)));
1193
+ }
1194
+ await tx.delete(entityEffectivePermissions).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityEffectivePermissions.tenantId, this.tenantId), drizzle_orm.sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`, (0, drizzle_orm.lt)(entityEffectivePermissions.expiresAt, now)));
1195
+ await tx.delete(entityTypePermissionGrants).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityTypePermissionGrants.tenantId, this.tenantId), drizzle_orm.sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`, (0, drizzle_orm.lt)(entityTypePermissionGrants.expiresAt, now)));
1196
+ });
1197
+ }
1198
+ async materializeEntityPermissionGrant(tx, grant) {
1199
+ await tx.delete(entityEffectivePermissions).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entityEffectivePermissions.tenantId, this.tenantId), (0, drizzle_orm.eq)(entityEffectivePermissions.sourceGrantId, grant.id)));
1200
+ if (grant.propagation === `descendants`) {
1201
+ await tx.execute(drizzle_orm.sql`
1202
+ INSERT INTO ${entityEffectivePermissions} (
1203
+ tenant_id,
1204
+ entity_url,
1205
+ source_entity_url,
1206
+ source_grant_id,
1207
+ permission,
1208
+ subject_kind,
1209
+ subject_value,
1210
+ expires_at
1211
+ )
1212
+ SELECT
1213
+ ${this.tenantId},
1214
+ descendant_url,
1215
+ ${grant.entityUrl},
1216
+ ${grant.id},
1217
+ ${grant.permission},
1218
+ ${grant.subjectKind},
1219
+ ${grant.subjectValue},
1220
+ ${grant.expiresAt}
1221
+ FROM ${entityLineage}
1222
+ WHERE tenant_id = ${this.tenantId}
1223
+ AND ancestor_url = ${grant.entityUrl}
1224
+ ON CONFLICT DO NOTHING
1225
+ `);
1226
+ return;
1227
+ }
1228
+ await tx.insert(entityEffectivePermissions).values({
1229
+ tenantId: this.tenantId,
1230
+ entityUrl: grant.entityUrl,
1231
+ sourceEntityUrl: grant.entityUrl,
1232
+ sourceGrantId: grant.id,
1233
+ permission: grant.permission,
1234
+ subjectKind: grant.subjectKind,
1235
+ subjectValue: grant.subjectValue,
1236
+ expiresAt: grant.expiresAt
1237
+ }).onConflictDoNothing();
1238
+ }
843
1239
  async updateStatus(entityUrl, status$4) {
844
1240
  const whereClause = isTerminalEntityStatus(status$4) ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`));
845
1241
  await this.db.update(entities).set({
@@ -941,7 +1337,9 @@ var PostgresRegistry = class {
941
1337
  tenantId: this.tenantId,
942
1338
  sourceRef: row.sourceRef,
943
1339
  tags: (0, __electric_ax_agents_runtime.normalizeTags)(row.tags),
944
- streamUrl: row.streamUrl
1340
+ streamUrl: row.streamUrl,
1341
+ principalUrl: row.principalUrl,
1342
+ principalKind: row.principalKind
945
1343
  }).onConflictDoNothing();
946
1344
  const existing = await this.getEntityBridge(row.sourceRef);
947
1345
  if (!existing) throw new Error(`Failed to load entity bridge ${row.sourceRef}`);
@@ -1103,20 +1501,46 @@ var PostgresRegistry = class {
1103
1501
  updated_at: row.updatedAt
1104
1502
  };
1105
1503
  }
1504
+ rowToEntityTypePermissionGrant(row) {
1505
+ return {
1506
+ id: row.id,
1507
+ entity_type: row.entityType,
1508
+ permission: row.permission,
1509
+ subject_kind: row.subjectKind,
1510
+ subject_value: row.subjectValue,
1511
+ created_by: row.createdBy ?? void 0,
1512
+ expires_at: row.expiresAt?.toISOString(),
1513
+ created_at: row.createdAt.toISOString(),
1514
+ updated_at: row.updatedAt.toISOString()
1515
+ };
1516
+ }
1517
+ rowToEntityPermissionGrant(row) {
1518
+ return {
1519
+ id: row.id,
1520
+ entity_url: row.entityUrl,
1521
+ permission: row.permission,
1522
+ subject_kind: row.subjectKind,
1523
+ subject_value: row.subjectValue,
1524
+ propagation: row.propagation,
1525
+ copy_to_children: row.copyToChildren,
1526
+ created_by: row.createdBy ?? void 0,
1527
+ expires_at: row.expiresAt?.toISOString(),
1528
+ created_at: row.createdAt.toISOString(),
1529
+ updated_at: row.updatedAt.toISOString()
1530
+ };
1531
+ }
1106
1532
  rowToEntity(row) {
1107
1533
  return {
1108
1534
  url: row.url,
1109
1535
  type: row.type,
1110
1536
  status: assertEntityStatus(row.status),
1111
- streams: {
1112
- main: `${row.url}/main`,
1113
- error: `${row.url}/error`
1114
- },
1537
+ streams: { main: `${row.url}/main` },
1115
1538
  subscription_id: row.subscriptionId,
1116
1539
  dispatch_policy: row.dispatchPolicy ?? void 0,
1117
1540
  write_token: row.writeToken,
1118
1541
  tags: row.tags ?? {},
1119
1542
  spawn_args: row.spawnArgs,
1543
+ sandbox: row.sandbox ?? void 0,
1120
1544
  parent: row.parent ?? void 0,
1121
1545
  created_by: row.createdBy ?? void 0,
1122
1546
  type_revision: row.typeRevision ?? void 0,
@@ -1132,6 +1556,8 @@ var PostgresRegistry = class {
1132
1556
  sourceRef: row.sourceRef,
1133
1557
  tags: row.tags ?? {},
1134
1558
  streamUrl: row.streamUrl,
1559
+ principalUrl: row.principalUrl ?? void 0,
1560
+ principalKind: row.principalKind ?? void 0,
1135
1561
  shapeHandle: row.shapeHandle ?? void 0,
1136
1562
  shapeOffset: row.shapeOffset ?? void 0,
1137
1563
  lastObserverActivityAt: row.lastObserverActivityAt,
@@ -1164,6 +1590,7 @@ var PostgresRegistry = class {
1164
1590
  kind: assertRunnerKind(row.kind),
1165
1591
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1166
1592
  wake_stream: row.wakeStream,
1593
+ sandbox_profiles: row.sandboxProfiles ?? [],
1167
1594
  created_at: row.createdAt.toISOString(),
1168
1595
  updated_at: row.updatedAt.toISOString()
1169
1596
  };
@@ -1285,6 +1712,93 @@ const serverLog = {
1285
1712
  }
1286
1713
  };
1287
1714
 
1715
+ //#endregion
1716
+ //#region src/principal.ts
1717
+ const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
1718
+ const PRINCIPAL_KINDS = new Set([
1719
+ `user`,
1720
+ `agent`,
1721
+ `service`,
1722
+ `system`
1723
+ ]);
1724
+ function parsePrincipalKey(input) {
1725
+ const colon = input.indexOf(`:`);
1726
+ if (colon <= 0) throw new Error(`Invalid principal identifier`);
1727
+ const kind = input.slice(0, colon);
1728
+ const id = input.slice(colon + 1);
1729
+ if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
1730
+ if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
1731
+ const key = `${kind}:${id}`;
1732
+ return {
1733
+ kind,
1734
+ id,
1735
+ key,
1736
+ url: `/principal/${encodeURIComponent(key)}`
1737
+ };
1738
+ }
1739
+ function principalUrl(key) {
1740
+ return parsePrincipalKey(key).url;
1741
+ }
1742
+ function parsePrincipalUrl(url) {
1743
+ if (!url.startsWith(`/principal/`)) return null;
1744
+ const segment = url.slice(`/principal/`.length);
1745
+ if (!segment || segment.includes(`/`)) return null;
1746
+ try {
1747
+ return parsePrincipalKey(decodeURIComponent(segment));
1748
+ } catch {
1749
+ return null;
1750
+ }
1751
+ }
1752
+ const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
1753
+ `framework`,
1754
+ `auth-sync`,
1755
+ `dev-local`
1756
+ ]);
1757
+ function isBuiltInSystemPrincipalUrl(url) {
1758
+ if (!url?.startsWith(`/principal/`)) return false;
1759
+ try {
1760
+ const principal = parsePrincipalUrl(url);
1761
+ if (!principal) return false;
1762
+ return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
1763
+ } catch {
1764
+ return false;
1765
+ }
1766
+ }
1767
+ function principalFromCreatedBy(createdBy) {
1768
+ if (!createdBy) return void 0;
1769
+ const principal = parsePrincipalUrl(createdBy);
1770
+ if (!principal) return {
1771
+ url: createdBy,
1772
+ key: null
1773
+ };
1774
+ return {
1775
+ url: principal.url,
1776
+ key: principal.key,
1777
+ kind: principal.kind,
1778
+ id: principal.id
1779
+ };
1780
+ }
1781
+ const principalIdentityStateSchema = __sinclair_typebox.Type.Object({
1782
+ kind: __sinclair_typebox.Type.Union([
1783
+ __sinclair_typebox.Type.Literal(`user`),
1784
+ __sinclair_typebox.Type.Literal(`agent`),
1785
+ __sinclair_typebox.Type.Literal(`service`),
1786
+ __sinclair_typebox.Type.Literal(`system`)
1787
+ ]),
1788
+ id: __sinclair_typebox.Type.String(),
1789
+ key: __sinclair_typebox.Type.String(),
1790
+ url: __sinclair_typebox.Type.String(),
1791
+ updated_at: __sinclair_typebox.Type.String(),
1792
+ display_name: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
1793
+ email: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
1794
+ avatar_url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
1795
+ auth_provider: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
1796
+ auth_subject: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
1797
+ claims: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
1798
+ created_at: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
1799
+ }, { additionalProperties: false });
1800
+ const principalUpdateIdentityMessageSchema = __sinclair_typebox.Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
1801
+
1288
1802
  //#endregion
1289
1803
  //#region src/entity-projector.ts
1290
1804
  const ENTITY_SHAPE_COLUMNS = [
@@ -1293,7 +1807,9 @@ const ENTITY_SHAPE_COLUMNS = [
1293
1807
  `type`,
1294
1808
  `status`,
1295
1809
  `tags`,
1810
+ `created_by`,
1296
1811
  `spawn_args`,
1812
+ `sandbox`,
1297
1813
  `parent`,
1298
1814
  `type_revision`,
1299
1815
  `inbox_schemas`,
@@ -1311,6 +1827,12 @@ function sourceRefFromStreamPath(streamPath) {
1311
1827
  const match = streamPath.match(/^\/_entities\/([^/]+)$/);
1312
1828
  return match?.[1] ?? null;
1313
1829
  }
1830
+ function principalScopedSourceRef(tagSourceRef, principalUrl$1, principalKind) {
1831
+ return `${tagSourceRef}-${(0, __electric_ax_agents_runtime.hashString)(JSON.stringify({
1832
+ principalKind,
1833
+ principalUrl: principalUrl$1
1834
+ }))}`;
1835
+ }
1314
1836
  function sameMember(left, right) {
1315
1837
  return JSON.stringify(left) === JSON.stringify(right);
1316
1838
  }
@@ -1327,6 +1849,7 @@ function toMemberRow(entity) {
1327
1849
  status: entity.status,
1328
1850
  tags: entity.tags,
1329
1851
  spawn_args: entity.spawn_args ?? {},
1852
+ sandbox: entity.sandbox ?? null,
1330
1853
  parent: entity.parent ?? null,
1331
1854
  type_revision: entity.type_revision ?? null,
1332
1855
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -1340,15 +1863,22 @@ var ProjectedEntityBridge = class {
1340
1863
  sourceRef;
1341
1864
  tags;
1342
1865
  streamUrl;
1866
+ principalUrl;
1867
+ principalKind;
1868
+ permissionBypass;
1343
1869
  currentMembers = new Map();
1344
1870
  producer = null;
1345
1871
  stopped = false;
1346
- constructor(row, streamClient) {
1872
+ constructor(row, registry, streamClient) {
1873
+ this.registry = registry;
1347
1874
  this.streamClient = streamClient;
1348
1875
  this.tenantId = row.tenantId;
1349
1876
  this.sourceRef = row.sourceRef;
1350
1877
  this.tags = (0, __electric_ax_agents_runtime.normalizeTags)(row.tags);
1351
1878
  this.streamUrl = row.streamUrl;
1879
+ this.principalUrl = row.principalUrl;
1880
+ this.principalKind = row.principalKind;
1881
+ this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl);
1352
1882
  }
1353
1883
  async start(initialEntities) {
1354
1884
  await this.ensureStream();
@@ -1362,7 +1892,7 @@ var ProjectedEntityBridge = class {
1362
1892
  }
1363
1893
  });
1364
1894
  await this.loadCurrentMembers();
1365
- this.reconcile(initialEntities);
1895
+ await this.reconcile(initialEntities);
1366
1896
  }
1367
1897
  async stop() {
1368
1898
  this.stopped = true;
@@ -1374,12 +1904,13 @@ var ProjectedEntityBridge = class {
1374
1904
  this.producer = null;
1375
1905
  }
1376
1906
  }
1377
- reconcile(entities$1) {
1907
+ async reconcile(entities$1) {
1378
1908
  if (this.stopped) return;
1379
1909
  const staleMembers = new Map(this.currentMembers);
1380
1910
  for (const entity of entities$1) {
1381
1911
  if (entity.tenant_id !== this.tenantId) continue;
1382
1912
  if (!entityMatchesTags(entity, this.tags)) continue;
1913
+ if (!await this.canReadEntity(entity)) continue;
1383
1914
  staleMembers.delete(entity.url);
1384
1915
  this.upsertEntity(entity);
1385
1916
  }
@@ -1388,10 +1919,10 @@ var ProjectedEntityBridge = class {
1388
1919
  this.currentMembers.delete(url);
1389
1920
  }
1390
1921
  }
1391
- applyEntity(entity) {
1922
+ async applyEntity(entity) {
1392
1923
  if (this.stopped) return;
1393
1924
  if (entity.tenant_id !== this.tenantId) return;
1394
- if (!entityMatchesTags(entity, this.tags)) {
1925
+ if (!entityMatchesTags(entity, this.tags) || !await this.canReadEntity(entity)) {
1395
1926
  const existing = this.currentMembers.get(entity.url);
1396
1927
  if (!existing) return;
1397
1928
  this.append(`delete`, existing);
@@ -1420,6 +1951,15 @@ var ProjectedEntityBridge = class {
1420
1951
  this.currentMembers.set(entity.url, next);
1421
1952
  }
1422
1953
  }
1954
+ async canReadEntity(entity) {
1955
+ if (this.permissionBypass) return true;
1956
+ if (!this.principalUrl || !this.principalKind) return false;
1957
+ if (entity.created_by === this.principalUrl) return true;
1958
+ return await this.registry.hasEntityPermission(entity.url, `read`, {
1959
+ principalUrl: this.principalUrl,
1960
+ principalKind: this.principalKind
1961
+ });
1962
+ }
1423
1963
  async ensureStream() {
1424
1964
  if (!await this.streamClient.exists(this.streamUrl)) await this.streamClient.create(this.streamUrl, { contentType: `application/json` });
1425
1965
  }
@@ -1524,17 +2064,19 @@ var EntityProjector = class {
1524
2064
  this.activeReaders.clear();
1525
2065
  await Promise.all(projections.map((projection) => projection.stop()));
1526
2066
  }
1527
- async register(tenantId, registry, tagsInput) {
2067
+ async register(tenantId, registry, tagsInput, principalUrl$1, principalKind) {
1528
2068
  if (!this.electricUrl) throw new Error(`[entity-projector] Electric URL is required for entities()`);
1529
2069
  await this.start();
1530
2070
  this.registries.set(tenantId, registry);
1531
2071
  const tags = (0, __electric_ax_agents_runtime.normalizeTags)((0, __electric_ax_agents_runtime.assertTags)(tagsInput));
1532
- const sourceRef = (0, __electric_ax_agents_runtime.sourceRefForTags)(tags);
2072
+ const sourceRef = principalScopedSourceRef((0, __electric_ax_agents_runtime.sourceRefForTags)(tags), principalUrl$1, principalKind);
1533
2073
  const streamUrl = (0, __electric_ax_agents_runtime.getEntitiesStreamPath)(sourceRef);
1534
2074
  const row = await registry.upsertEntityBridge({
1535
2075
  sourceRef,
1536
2076
  tags,
1537
- streamUrl
2077
+ streamUrl,
2078
+ principalUrl: principalUrl$1,
2079
+ principalKind
1538
2080
  });
1539
2081
  await registry.touchEntityBridge(sourceRef);
1540
2082
  await this.ensureProjection(row);
@@ -1563,7 +2105,11 @@ var EntityProjector = class {
1563
2105
  await this.touchSourceRef(tenantId, registry, sourceRef, `read-close`);
1564
2106
  };
1565
2107
  }
1566
- async onEntityChanged(_tenantId, _entityUrl) {}
2108
+ async onEntityChanged(tenantId, entityUrl) {
2109
+ const entity = this.entities.get(entityKey(tenantId, entityUrl));
2110
+ if (!entity) return;
2111
+ for (const projection of this.projectionsForTenant(tenantId)) await projection.applyEntity(entity);
2112
+ }
1567
2113
  async loadTenantBridges(tenantId, registry = this.registryForTenant(tenantId)) {
1568
2114
  if (!this.started || !this.electricUrl) return;
1569
2115
  await this.loadPersistedBridgesForTenant(tenantId, registry);
@@ -1624,16 +2170,16 @@ var EntityProjector = class {
1624
2170
  }
1625
2171
  if (message.headers.control === `up-to-date`) {
1626
2172
  this.upToDate = true;
1627
- this.reconcileAll();
2173
+ await this.reconcileAll();
1628
2174
  this.readyResolve?.();
1629
2175
  }
1630
2176
  continue;
1631
2177
  }
1632
2178
  if (!(0, __electric_sql_client.isChangeMessage)(message)) continue;
1633
- this.applyChangeMessage(message);
2179
+ await this.applyChangeMessage(message);
1634
2180
  }
1635
2181
  }
1636
- applyChangeMessage(message) {
2182
+ async applyChangeMessage(message) {
1637
2183
  const entity = message.value;
1638
2184
  const key = entityKey(entity.tenant_id, entity.url);
1639
2185
  if (message.headers.operation === `delete`) {
@@ -1642,7 +2188,7 @@ var EntityProjector = class {
1642
2188
  return;
1643
2189
  }
1644
2190
  this.entities.set(key, entity);
1645
- if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) projection.applyEntity(entity);
2191
+ if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) await projection.applyEntity(entity);
1646
2192
  }
1647
2193
  async loadPersistedBridges() {
1648
2194
  const registry = new PostgresRegistry(this.db);
@@ -1705,7 +2251,7 @@ var EntityProjector = class {
1705
2251
  }
1706
2252
  throw error;
1707
2253
  }
1708
- const projection = new ProjectedEntityBridge(row, streamClient);
2254
+ const projection = new ProjectedEntityBridge(row, this.registryForTenant(row.tenantId), streamClient);
1709
2255
  await projection.start(this.entitiesForTenant(row.tenantId));
1710
2256
  this.projections.set(key, projection);
1711
2257
  })().finally(() => {
@@ -1720,8 +2266,8 @@ var EntityProjector = class {
1720
2266
  projectionsForTenant(tenantId) {
1721
2267
  return [...this.projections.values()].filter((projection) => projection.tenantId === tenantId);
1722
2268
  }
1723
- reconcileAll() {
1724
- for (const projection of this.projections.values()) projection.reconcile(this.entitiesForTenant(projection.tenantId));
2269
+ async reconcileAll() {
2270
+ for (const projection of this.projections.values()) await projection.reconcile(this.entitiesForTenant(projection.tenantId));
1725
2271
  }
1726
2272
  async touchSourceRef(tenantId, registry, sourceRef, reason) {
1727
2273
  try {
@@ -1763,8 +2309,8 @@ var EntityProjectorTenantFacade = class {
1763
2309
  await this.projector.start();
1764
2310
  }
1765
2311
  async stop() {}
1766
- async register(tagsInput) {
1767
- return await this.projector.register(this.tenantId, this.registry, tagsInput);
2312
+ async register(tagsInput, principalUrl$1, principalKind) {
2313
+ return await this.projector.register(this.tenantId, this.registry, tagsInput, principalUrl$1, principalKind);
1768
2314
  }
1769
2315
  async onEntityChanged(entityUrl) {
1770
2316
  await this.projector.onEntityChanged(this.tenantId, entityUrl);
@@ -2007,7 +2553,7 @@ var StreamClient = class {
2007
2553
  });
2008
2554
  });
2009
2555
  }
2010
- async fork(path$2, sourcePath) {
2556
+ async fork(path$2, sourcePath, opts) {
2011
2557
  return await withSpan(`stream.fork`, async (span) => {
2012
2558
  span.setAttributes({
2013
2559
  [ATTR.STREAM_PATH]: path$2,
@@ -2017,6 +2563,11 @@ var StreamClient = class {
2017
2563
  "content-type": `application/json`,
2018
2564
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
2019
2565
  };
2566
+ if (opts?.forkPointer) {
2567
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`;
2568
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
2569
+ if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
2570
+ }
2020
2571
  injectTraceHeaders(headers);
2021
2572
  const response = await fetch(this.streamUrl(path$2), {
2022
2573
  method: `PUT`,
@@ -2545,91 +3096,101 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2545
3096
  }
2546
3097
 
2547
3098
  //#endregion
2548
- //#region src/principal.ts
2549
- const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
2550
- const PRINCIPAL_KINDS = new Set([
2551
- `user`,
2552
- `agent`,
2553
- `service`,
2554
- `system`
2555
- ]);
2556
- function parsePrincipalKey(input) {
2557
- const colon = input.indexOf(`:`);
2558
- if (colon <= 0) throw new Error(`Invalid principal identifier`);
2559
- const kind = input.slice(0, colon);
2560
- const id = input.slice(colon + 1);
2561
- if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
2562
- if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
2563
- const key = `${kind}:${id}`;
3099
+ //#region src/routing/sandbox.ts
3100
+ /**
3101
+ * Resolve and validate a spawn's sandbox CHOICE into the {@link
3102
+ * EntitySandboxSelection} persisted on the entity. Sibling of
3103
+ * `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
3104
+ * EntityManager so the spawn path reads as composed resolution steps.
3105
+ *
3106
+ * Profiles are a per-runner concern: each runner advertises what it supports.
3107
+ * When the spawn pins a runner via dispatch_policy, the chosen profile must be
3108
+ * in that runner's advertised set; otherwise we'd persist an unserviceable
3109
+ * choice that fails late at first wake. For unpinned dispatch (webhook /
3110
+ * parent-inherited) we can't pick a target ahead of time, so we fall back to a
3111
+ * tenant-wide "some runner offers this" check — better than nothing.
3112
+ */
3113
+ async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
3114
+ if (!requested) return void 0;
3115
+ const choice = applyInheritedSandbox(requested, parentEntity);
3116
+ if (!choice) return void 0;
3117
+ const chosenName = choice.profile;
3118
+ if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
3119
+ const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
3120
+ assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
3121
+ const selection = { profile: chosenName };
3122
+ if (choice.key !== void 0) selection.key = choice.key;
3123
+ else if (choice.scope !== void 0) selection.scope = choice.scope;
3124
+ if (choice.persistent !== void 0) selection.persistent = choice.persistent;
3125
+ if (choice.owner === false) selection.owner = false;
3126
+ return selection;
3127
+ }
3128
+ /**
3129
+ * Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
3130
+ * parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
3131
+ * parent has no shareable (keyed) sandbox the child simply gets none (returns
3132
+ * `undefined`), so `spawn_worker` can always request inheritance without
3133
+ * breaking unkeyed parents. (A running parent wake resolves inherit to its live
3134
+ * explicit key in the runtime instead — this server-side path covers direct API
3135
+ * callers, where only the parent's *stored* explicit key is available.)
3136
+ *
3137
+ * For a non-inherit choice the request passes through unchanged.
3138
+ *
3139
+ * NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
3140
+ * any sibling field on the request (e.g. a caller-supplied `persistent: false`)
3141
+ * is intentionally ignored, because a child attaches to the parent's existing
3142
+ * sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
3143
+ * permits the `{ inherit: true, persistent: ... }` combination, so the
3144
+ * precedence is resolved here rather than rejected at the schema level.
3145
+ */
3146
+ function applyInheritedSandbox(requested, parentEntity) {
3147
+ if (!requested.inherit) return requested;
3148
+ const parentKey = parentEntity?.sandbox?.key;
3149
+ if (!parentKey) return void 0;
2564
3150
  return {
2565
- kind,
2566
- id,
2567
- key,
2568
- url: `/principal/${encodeURIComponent(key)}`
3151
+ profile: parentEntity.sandbox.profile,
3152
+ key: parentKey,
3153
+ persistent: parentEntity.sandbox.persistent,
3154
+ owner: false
2569
3155
  };
2570
3156
  }
2571
- function principalUrl(key) {
2572
- return parsePrincipalKey(key).url;
2573
- }
2574
- function parsePrincipalUrl(url) {
2575
- if (!url.startsWith(`/principal/`)) return null;
2576
- const segment = url.slice(`/principal/`.length);
2577
- if (!segment || segment.includes(`/`)) return null;
2578
- try {
2579
- return parsePrincipalKey(decodeURIComponent(segment));
2580
- } catch {
2581
- return null;
2582
- }
2583
- }
2584
- const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
2585
- `framework`,
2586
- `auth-sync`,
2587
- `dev-local`
2588
- ]);
2589
- function isBuiltInSystemPrincipalUrl(url) {
2590
- if (!url?.startsWith(`/principal/`)) return false;
2591
- try {
2592
- const principal = parsePrincipalUrl(url);
2593
- if (!principal) return false;
2594
- return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
2595
- } catch {
2596
- return false;
2597
- }
3157
+ /**
3158
+ * Validate the chosen profile is advertised by the relevant runner(s) and
3159
+ * determine whether it is a remote (off-host) sandbox, reachable from any
3160
+ * runner. Defaults to host-local (co-location required) unless every relevant
3161
+ * advertisement marks it remote. Throws if the profile is unserviceable.
3162
+ */
3163
+ async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
3164
+ const runnerIds = [];
3165
+ for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
3166
+ if (runnerIds.length > 0) {
3167
+ let allRemote = true;
3168
+ for (const runnerId of runnerIds) {
3169
+ const runner = await registry.getRunner(runnerId);
3170
+ const advertised = runner?.sandbox_profiles ?? [];
3171
+ const match = advertised.find((p) => p.name === chosenName);
3172
+ if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
3173
+ if (match.remote !== true) allRemote = false;
3174
+ }
3175
+ return allRemote;
3176
+ }
3177
+ const available = await registry.listSandboxProfiles();
3178
+ const matches = available.filter((p) => p.name === chosenName);
3179
+ 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);
3180
+ return matches.every((p) => p.remote === true);
2598
3181
  }
2599
- function principalFromCreatedBy(createdBy) {
2600
- if (!createdBy) return void 0;
2601
- const principal = parsePrincipalUrl(createdBy);
2602
- if (!principal) return {
2603
- url: createdBy,
2604
- key: null
2605
- };
2606
- return {
2607
- url: principal.url,
2608
- key: principal.key,
2609
- kind: principal.kind,
2610
- id: principal.id
2611
- };
3182
+ /**
3183
+ * Co-location: a shared *local* sandbox lives on one host, so every
3184
+ * collaborator must be pinned to the same single runner. Subagents inherit the
3185
+ * parent's dispatch policy, so this holds once the root is pinned. A shared
3186
+ * *remote* sandbox is reachable from any runner, so the guard does not apply.
3187
+ */
3188
+ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3189
+ if (key === void 0 || chosenIsRemote) return;
3190
+ const targets = dispatchPolicy?.targets ?? [];
3191
+ const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
3192
+ 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);
2612
3193
  }
2613
- const principalIdentityStateSchema = __sinclair_typebox.Type.Object({
2614
- kind: __sinclair_typebox.Type.Union([
2615
- __sinclair_typebox.Type.Literal(`user`),
2616
- __sinclair_typebox.Type.Literal(`agent`),
2617
- __sinclair_typebox.Type.Literal(`service`),
2618
- __sinclair_typebox.Type.Literal(`system`)
2619
- ]),
2620
- id: __sinclair_typebox.Type.String(),
2621
- key: __sinclair_typebox.Type.String(),
2622
- url: __sinclair_typebox.Type.String(),
2623
- updated_at: __sinclair_typebox.Type.String(),
2624
- display_name: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
2625
- email: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
2626
- avatar_url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
2627
- auth_provider: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
2628
- auth_subject: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
2629
- claims: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
2630
- created_at: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
2631
- }, { additionalProperties: false });
2632
- const principalUpdateIdentityMessageSchema = __sinclair_typebox.Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
2633
3194
 
2634
3195
  //#endregion
2635
3196
  //#region src/manifest-side-effects.ts
@@ -2866,7 +3427,10 @@ var EntityManager = class {
2866
3427
  }
2867
3428
  async ensurePrincipal(principal) {
2868
3429
  const existing = await this.registry.getEntity(principal.url);
2869
- if (existing) return existing;
3430
+ if (existing) {
3431
+ await this.ensureUserPrincipal(principal);
3432
+ return existing;
3433
+ }
2870
3434
  await this.ensurePrincipalEntityType();
2871
3435
  try {
2872
3436
  const entity = await this.spawn(`principal`, {
@@ -2895,15 +3459,22 @@ var EntityManager = class {
2895
3459
  updated_at: now
2896
3460
  }
2897
3461
  }));
3462
+ await this.ensureUserPrincipal(principal);
2898
3463
  return entity;
2899
3464
  } catch (error) {
2900
3465
  if (error instanceof ElectricAgentsError && error.code === ErrCodeDuplicateURL) {
2901
3466
  const raced = await this.registry.getEntity(principal.url);
2902
- if (raced) return raced;
3467
+ if (raced) {
3468
+ await this.ensureUserPrincipal(principal);
3469
+ return raced;
3470
+ }
2903
3471
  }
2904
3472
  throw error;
2905
3473
  }
2906
3474
  }
3475
+ async ensureUserPrincipal(principal) {
3476
+ if (principal.kind === `user`) await this.registry.ensureUserForPrincipal(principal);
3477
+ }
2907
3478
  /**
2908
3479
  * Spawn a new entity of the given type with durable streams.
2909
3480
  */
@@ -2933,7 +3504,6 @@ var EntityManager = class {
2933
3504
  const writeToken = (0, node_crypto.randomUUID)();
2934
3505
  const entityURL = typeName === `principal` ? principalUrl(instanceId) : `/${typeName}/${instanceId}`;
2935
3506
  const mainPath = `${entityURL}/main`;
2936
- const errorPath = `${entityURL}/error`;
2937
3507
  const subscriptionId = `${typeName}-handler`;
2938
3508
  const spawnT0 = performance.now();
2939
3509
  const existingByURL = await this.registry.getEntity(entityURL);
@@ -2944,20 +3514,19 @@ var EntityManager = class {
2944
3514
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2945
3515
  }
2946
3516
  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;
3517
+ const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
2947
3518
  const now = Date.now();
2948
3519
  const entityData = {
2949
3520
  type: typeName,
2950
3521
  status: `idle`,
2951
3522
  url: entityURL,
2952
- streams: {
2953
- main: mainPath,
2954
- error: errorPath
2955
- },
3523
+ streams: { main: mainPath },
2956
3524
  subscription_id: subscriptionId,
2957
3525
  dispatch_policy: dispatchPolicy,
2958
3526
  write_token: writeToken,
2959
3527
  tags: initialTags,
2960
3528
  spawn_args: req.args,
3529
+ sandbox,
2961
3530
  type_revision: entityType.revision,
2962
3531
  inbox_schemas: entityType.inbox_schemas,
2963
3532
  state_schemas: entityType.state_schemas,
@@ -3004,55 +3573,43 @@ var EntityManager = class {
3004
3573
  const queueEnterT0 = performance.now();
3005
3574
  const queueWaiting = this.spawnPersistQueue.length();
3006
3575
  const queueRunning = this.spawnPersistQueue.running();
3007
- const [mainStreamResult, errorStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
3576
+ const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
3008
3577
  let entityTxid;
3009
3578
  try {
3010
3579
  entityTxid = await withSpan(`db.createEntity`, () => this.registry.createEntity(entityData));
3011
3580
  } catch (err) {
3012
- return [
3013
- {
3014
- status: `fulfilled`,
3015
- value: void 0
3016
- },
3017
- {
3018
- status: `fulfilled`,
3019
- value: void 0
3020
- },
3021
- {
3022
- status: `rejected`,
3023
- reason: err
3024
- }
3025
- ];
3581
+ return [{
3582
+ status: `fulfilled`,
3583
+ value: void 0
3584
+ }, {
3585
+ status: `rejected`,
3586
+ reason: err
3587
+ }];
3026
3588
  }
3027
- const [mainStreamResult$1, errorStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
3589
+ const [mainStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
3028
3590
  contentType,
3029
3591
  body: initialBody
3030
- }), this.streamClient.create(errorPath, { contentType })]);
3031
- return [
3032
- mainStreamResult$1,
3033
- errorStreamResult$1,
3034
- {
3035
- status: `fulfilled`,
3036
- value: entityTxid
3037
- }
3038
- ];
3592
+ })]);
3593
+ return [mainStreamResult$1, {
3594
+ status: `fulfilled`,
3595
+ value: entityTxid
3596
+ }];
3039
3597
  });
3040
3598
  const parallelMs = +(performance.now() - queueEnterT0).toFixed(2);
3041
- if (mainStreamResult.status === `rejected` || errorStreamResult.status === `rejected` || entityResult.status === `rejected`) {
3599
+ if (mainStreamResult.status === `rejected` || entityResult.status === `rejected`) {
3042
3600
  const entityReason = entityResult.status === `rejected` ? entityResult.reason : null;
3043
- const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : errorStreamResult.status === `rejected` ? errorStreamResult.reason : null;
3601
+ const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : null;
3044
3602
  const isDuplicate = entityReason instanceof EntityAlreadyExistsError;
3045
3603
  const isStreamConflict = !!streamReason && typeof streamReason === `object` && (`status` in streamReason && streamReason.status === 409 || `code` in streamReason && streamReason.code === `CONFLICT_SEQ`);
3046
3604
  const rollbacks = [];
3047
3605
  if (!isDuplicate && !isStreamConflict) {
3048
3606
  if (mainStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(mainPath));
3049
- if (errorStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(errorPath));
3050
3607
  if (entityResult.status === `fulfilled`) rollbacks.push(this.registry.deleteEntity(entityURL));
3051
3608
  if (req.wake) rollbacks.push(this.wakeRegistry.unregisterBySubscriberAndSource(req.wake.subscriberUrl, entityURL, this.tenantId));
3052
3609
  await Promise.allSettled(rollbacks);
3053
3610
  }
3054
3611
  if (isDuplicate || isStreamConflict) throw new ElectricAgentsError(ErrCodeDuplicateURL, `Entity already exists at URL "${entityURL}"`, 409);
3055
- const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : errorStreamResult.status === `rejected` ? errorStreamResult.reason : entityResult.reason;
3612
+ const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : entityResult.reason;
3056
3613
  if (failure instanceof Error) throw failure;
3057
3614
  throw new ElectricAgentsError(`SPAWN_FAILED`, `Spawn failed: ${String(failure)}`, 500);
3058
3615
  }
@@ -3087,30 +3644,67 @@ var EntityManager = class {
3087
3644
  const writeEntityLocks = new Set();
3088
3645
  const writeStreamLocks = new Set();
3089
3646
  try {
3090
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3647
+ let sourceTree;
3648
+ if (opts.forkPointer) {
3649
+ const rootEntity = await this.registry.getEntity(rootUrl);
3650
+ if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3651
+ if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
3652
+ sourceTree = await this.listEntitySubtree(rootEntity);
3653
+ } else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3091
3654
  const sourceRoot = sourceTree[0];
3092
3655
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3093
- const snapshot = await this.readForkStateSnapshot(sourceTree);
3656
+ let preFilteredRoot;
3657
+ if (opts.forkPointer) {
3658
+ const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3659
+ const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3660
+ const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3661
+ const filteredEvents = flat.slice(0, target);
3662
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3663
+ const sharedStateIds = new Set();
3664
+ for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
3665
+ preFilteredRoot = {
3666
+ manifests: rootManifests,
3667
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
3668
+ replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
3669
+ sharedStateIds
3670
+ };
3671
+ }
3672
+ const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3673
+ if (opts.forkPointer) {
3674
+ const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3675
+ if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3676
+ }
3677
+ const snapshot = await this.readForkStateSnapshot(
3678
+ // Skip the root when we've already pre-filtered it — avoid both a
3679
+ // wasted HEAD read of main and a re-population that would clobber
3680
+ // the filtered entries.
3681
+ preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
3682
+ );
3683
+ if (preFilteredRoot) {
3684
+ snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
3685
+ snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
3686
+ snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
3687
+ for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
3688
+ }
3094
3689
  const suffix = (0, node_crypto.randomUUID)().slice(0, 8);
3095
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3690
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3096
3691
  suffix,
3097
3692
  rootUrl,
3098
3693
  rootInstanceId: opts.rootInstanceId
3099
3694
  });
3100
3695
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3101
3696
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3102
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3103
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
3697
+ const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
3698
+ this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3104
3699
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(id)), writeStreamLocks);
3105
3700
  const createdStreams = [];
3106
3701
  const createdEntities = [];
3107
3702
  const activeManifestsByEntity = new Map();
3108
3703
  try {
3109
3704
  for (const plan of entityPlans) {
3110
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
3705
+ const isRoot = plan.source.url === rootUrl;
3706
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3111
3707
  createdStreams.push(plan.fork.streams.main);
3112
- await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3113
- createdStreams.push(plan.fork.streams.error);
3114
3708
  }
3115
3709
  for (const [sourceId, forkId] of sharedStateIdMap) {
3116
3710
  const sourcePath = (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(sourceId);
@@ -3200,6 +3794,38 @@ var EntityManager = class {
3200
3794
  }
3201
3795
  held.clear();
3202
3796
  }
3797
+ /**
3798
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
3799
+ * list instead of walking the registry from `rootUrl`. Used by the
3800
+ * pointer-fork path to wait+lock only the kept descendants, since
3801
+ * the root is being forked from history and doesn't need to be idle.
3802
+ */
3803
+ async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
3804
+ if (entities$1.length === 0) return;
3805
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3806
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
3807
+ const refresh = async () => {
3808
+ const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
3809
+ return refreshed.filter((entity) => !!entity);
3810
+ };
3811
+ const deadline = Date.now() + timeoutMs;
3812
+ while (true) {
3813
+ const present = await refresh();
3814
+ const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
3815
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3816
+ let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3817
+ if (active.length === 0) {
3818
+ this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
3819
+ const reChecked = await refresh();
3820
+ const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3821
+ if (reActive.length === 0) return;
3822
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3823
+ active = reActive;
3824
+ }
3825
+ if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
3826
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3827
+ }
3828
+ }
3203
3829
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3204
3830
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3205
3831
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3229,6 +3855,73 @@ var EntityManager = class {
3229
3855
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3230
3856
  }
3231
3857
  }
3858
+ /**
3859
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
3860
+ * source's flattened history. Throws a 400 if the pointer doesn't
3861
+ * address a real event.
3862
+ *
3863
+ * Semantics (mirroring the durable-streams server interpretation):
3864
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
3865
+ * messages forward." Concretely, the target event is the N-th event
3866
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
3867
+ * `null`, the anchor is the stream start and the target is the N-th
3868
+ * event from the very beginning.) The returned position is the count
3869
+ * of events to KEEP — events 1..position survive the filter.
3870
+ *
3871
+ * A pointer is valid when:
3872
+ * - `pointer.offset` is `null` (stream start) OR matches some
3873
+ * event's `headers.offset` value, AND
3874
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
3875
+ */
3876
+ resolveForkPointerTarget(events, pointer, streamPath) {
3877
+ let positionAtAnchor = 0;
3878
+ let anchorSeen = pointer.offset === null;
3879
+ for (const event of events) {
3880
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3881
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
3882
+ if (eventOffset === void 0) continue;
3883
+ if (pointer.offset === null) continue;
3884
+ if (eventOffset === pointer.offset) anchorSeen = true;
3885
+ if (eventOffset <= pointer.offset) positionAtAnchor++;
3886
+ }
3887
+ 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);
3888
+ const eventsPastAnchor = events.length - positionAtAnchor;
3889
+ 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);
3890
+ return positionAtAnchor + pointer.subOffset;
3891
+ }
3892
+ /**
3893
+ * Compute the subset of `sourceTree` that survives the manifest filter
3894
+ * applied at the root. After filtering the root's manifest at the fork
3895
+ * pointer, only children whose manifest entries landed at or before the
3896
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
3897
+ * along with them. Children dropped from the root's manifest, and any
3898
+ * of their descendants, are excluded.
3899
+ */
3900
+ computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
3901
+ const keptChildUrls = new Set();
3902
+ for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
3903
+ const childrenByParent = new Map();
3904
+ for (const entity of sourceTree) {
3905
+ if (!entity.parent) continue;
3906
+ const list = childrenByParent.get(entity.parent) ?? [];
3907
+ list.push(entity);
3908
+ childrenByParent.set(entity.parent, list);
3909
+ }
3910
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl);
3911
+ if (!rootEntity) return [];
3912
+ const result = [rootEntity];
3913
+ const queue = [];
3914
+ for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
3915
+ const seen = new Set([rootUrl]);
3916
+ while (queue.length > 0) {
3917
+ const entity = queue.shift();
3918
+ if (seen.has(entity.url)) continue;
3919
+ seen.add(entity.url);
3920
+ result.push(entity);
3921
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
3922
+ }
3923
+ return result;
3924
+ }
3232
3925
  async listEntitySubtree(root) {
3233
3926
  const result = [];
3234
3927
  const queue = [root];
@@ -3345,7 +4038,6 @@ var EntityManager = class {
3345
4038
  for (const [sourceUrl, forkUrl] of entityUrlMap) {
3346
4039
  stringMap.set(sourceUrl, forkUrl);
3347
4040
  stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`);
3348
- stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`);
3349
4041
  }
3350
4042
  for (const [sourceId, forkId] of sharedStateIdMap) {
3351
4043
  stringMap.set(sourceId, forkId);
@@ -3353,7 +4045,7 @@ var EntityManager = class {
3353
4045
  }
3354
4046
  return stringMap;
3355
4047
  }
3356
- buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap) {
4048
+ buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap, createdBy) {
3357
4049
  const now = Date.now();
3358
4050
  return entitiesToFork.map((source) => {
3359
4051
  const forkUrl = entityUrlMap.get(source.url);
@@ -3366,14 +4058,12 @@ var EntityManager = class {
3366
4058
  url: forkUrl,
3367
4059
  type,
3368
4060
  status: `idle`,
3369
- streams: {
3370
- main: `${forkUrl}/main`,
3371
- error: `${forkUrl}/error`
3372
- },
4061
+ streams: { main: `${forkUrl}/main` },
3373
4062
  subscription_id: `${type}-handler`,
3374
4063
  write_token: (0, node_crypto.randomUUID)(),
3375
4064
  spawn_args: spawnArgs,
3376
4065
  parent,
4066
+ created_by: createdBy ?? source.created_by,
3377
4067
  created_at: now,
3378
4068
  updated_at: now
3379
4069
  };
@@ -3607,7 +4297,7 @@ var EntityManager = class {
3607
4297
  }
3608
4298
  async materializeForkManifestSideEffects(entityUrl, manifests) {
3609
4299
  for (const [manifestKey, manifest] of manifests) {
3610
- await this.syncEntitiesManifestSource(entityUrl, manifestKey, `upsert`, manifest);
4300
+ await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest);
3611
4301
  const wake = buildManifestWakeRegistration(entityUrl, manifest, manifestKey);
3612
4302
  if (wake) await this.wakeRegistry.register({
3613
4303
  ...wake,
@@ -3637,6 +4327,7 @@ var EntityManager = class {
3637
4327
  await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
3638
4328
  entityUrl: targetUrl,
3639
4329
  from: senderUrl,
4330
+ from_agent: senderUrl,
3640
4331
  payload: manifest.payload,
3641
4332
  key: `scheduled-${producerId}`,
3642
4333
  type: typeof manifest.messageType === `string` ? manifest.messageType : void 0,
@@ -3676,12 +4367,14 @@ var EntityManager = class {
3676
4367
  const now = new Date().toISOString();
3677
4368
  const key = req.key ?? `msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3678
4369
  const value = {
3679
- from: req.from,
4370
+ from: req.from_principal ?? req.from,
3680
4371
  payload: req.payload,
3681
4372
  timestamp: now,
3682
4373
  mode: req.mode ?? `immediate`,
3683
4374
  status: req.mode === `queued` || req.mode === `paused` ? `pending` : `processed`
3684
4375
  };
4376
+ if (req.from_principal) value.from_principal = req.from_principal;
4377
+ if (req.from_agent) value.from_agent = req.from_agent;
3685
4378
  if (req.type) value.message_type = req.type;
3686
4379
  if (req.position) value.position = req.position;
3687
4380
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
@@ -3853,9 +4546,9 @@ var EntityManager = class {
3853
4546
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3854
4547
  return updated;
3855
4548
  }
3856
- async ensureEntitiesMembershipStream(tags) {
4549
+ async ensureEntitiesMembershipStream(tags, principal) {
3857
4550
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
3858
- return this.entityBridgeManager.register(this.validateTags(tags));
4551
+ return this.entityBridgeManager.register(this.validateTags(tags), principal.url, principal.kind);
3859
4552
  }
3860
4553
  async writeManifestEntry(entityUrl, key, operation, value, opts) {
3861
4554
  const entity = await this.registry.getEntity(entityUrl);
@@ -3873,11 +4566,11 @@ var EntityManager = class {
3873
4566
  const encoded = this.encodeChangeEvent(event);
3874
4567
  if (opts?.producerId) {
3875
4568
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
3876
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value);
4569
+ await this.syncManifestLinks(entityUrl, key, operation, value);
3877
4570
  return;
3878
4571
  }
3879
4572
  await this.streamClient.append(entity.streams.main, encoded);
3880
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value);
4573
+ await this.syncManifestLinks(entityUrl, key, operation, value);
3881
4574
  }
3882
4575
  async upsertCronSchedule(entityUrl, req) {
3883
4576
  if (req.payload === void 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Missing required field: payload`, 400);
@@ -4026,6 +4719,8 @@ var EntityManager = class {
4026
4719
  await this.scheduler.enqueueDelayedSend({
4027
4720
  entityUrl,
4028
4721
  from: req.from,
4722
+ from_principal: req.from_principal,
4723
+ from_agent: req.from_agent,
4029
4724
  payload: req.payload,
4030
4725
  key: req.key,
4031
4726
  type: req.type,
@@ -4068,14 +4763,23 @@ var EntityManager = class {
4068
4763
  await this.streamClient.appendIdempotent(subscriber.streams.main, this.encodeChangeEvent(wakeEvent), { producerId: `wake-reg-${result.registrationDbId}-${result.sourceEventKey}` });
4069
4764
  });
4070
4765
  }
4071
- async syncEntitiesManifestSource(entityUrl, manifestKey, operation, value) {
4766
+ async syncManifestLinks(entityUrl, manifestKey, operation, value) {
4072
4767
  const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
4073
4768
  await this.registry.replaceEntityManifestSource(entityUrl, manifestKey, sourceRef);
4769
+ const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
4770
+ await this.registry.replaceSharedStateLink(entityUrl, manifestKey, sharedStateId);
4074
4771
  }
4075
4772
  extractEntitiesSourceRef(manifest) {
4076
4773
  if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
4077
4774
  return void 0;
4078
4775
  }
4776
+ extractSharedStateId(manifest) {
4777
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
4778
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
4779
+ if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
4780
+ const config = isRecord(manifest.config) ? manifest.config : void 0;
4781
+ return typeof config?.id === `string` ? config.id : void 0;
4782
+ }
4079
4783
  /**
4080
4784
  * Read a child entity's stream and extract concatenated text deltas
4081
4785
  * for a specific run, plus any error messages for that run.
@@ -4239,14 +4943,7 @@ var EntityManager = class {
4239
4943
  await this.streamClient.append(entity.streams.main, signalData);
4240
4944
  return;
4241
4945
  }
4242
- const errorCloseEvent = {
4243
- type: `signal`,
4244
- key: signalEvent.key,
4245
- value: signalEvent.value,
4246
- headers: signalEvent.headers
4247
- };
4248
- const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4249
- for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4946
+ for (const [streamPath, data] of [[entity.streams.main, signalData]]) try {
4250
4947
  await this.streamClient.append(streamPath, data, { close: true });
4251
4948
  } catch (err) {
4252
4949
  const message = err instanceof Error ? err.message : String(err);
@@ -4336,6 +5033,7 @@ var EntityManager = class {
4336
5033
  streams: entity.streams,
4337
5034
  tags: entity.tags,
4338
5035
  spawnArgs: entity.spawn_args,
5036
+ sandbox: entity.sandbox,
4339
5037
  createdBy: entity.created_by
4340
5038
  },
4341
5039
  principal: principalFromCreatedBy(entity.created_by),
@@ -5228,6 +5926,8 @@ var ElectricAgentsTenantRuntime = class {
5228
5926
  try {
5229
5927
  await this.manager.send(payload.entityUrl, {
5230
5928
  from: payload.from,
5929
+ from_principal: payload.from_principal,
5930
+ from_agent: payload.from_agent,
5231
5931
  payload: payload.payload,
5232
5932
  key: payload.key ?? `scheduled-task-${taskId}`,
5233
5933
  type: payload.type
@@ -5300,6 +6000,7 @@ var ElectricAgentsTenantRuntime = class {
5300
6000
  await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
5301
6001
  entityUrl: targetUrl,
5302
6002
  from: senderUrl,
6003
+ from_agent: senderUrl,
5303
6004
  payload: value.payload,
5304
6005
  key: `scheduled-${producerId}`,
5305
6006
  type: typeof value.messageType === `string` ? value.messageType : void 0,
@@ -5324,11 +6025,20 @@ var ElectricAgentsTenantRuntime = class {
5324
6025
  async applyManifestEntitySource(ownerEntityUrl, manifestKey, operation, value) {
5325
6026
  const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
5326
6027
  await this.manager.registry.replaceEntityManifestSource(ownerEntityUrl, manifestKey, sourceRef);
6028
+ const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
6029
+ await this.manager.registry.replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId);
5327
6030
  }
5328
6031
  extractEntitiesSourceRef(manifest) {
5329
6032
  if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
5330
6033
  return void 0;
5331
6034
  }
6035
+ extractSharedStateId(manifest) {
6036
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
6037
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
6038
+ if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
6039
+ const config = typeof manifest.config === `object` && manifest.config !== null && !Array.isArray(manifest.config) ? manifest.config : void 0;
6040
+ return typeof config?.id === `string` ? config.id : void 0;
6041
+ }
5332
6042
  async maybeMarkEntityIdleAfterRunFinished(entityUrl) {
5333
6043
  const primaryStream = `${entityUrl}/main`;
5334
6044
  const callbacks = await this.db.select().from(consumerCallbacks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerCallbacks.tenantId, this.serviceId), (0, drizzle_orm.eq)(consumerCallbacks.primaryStream, primaryStream))).limit(1);
@@ -6001,11 +6711,21 @@ var WakeRegistry = class {
6001
6711
  }
6002
6712
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
6003
6713
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
6004
- return { change: {
6714
+ const change = {
6005
6715
  collection: eventType,
6006
6716
  kind,
6007
6717
  key: event.key || ``
6008
- } };
6718
+ };
6719
+ if (eventType === `inbox`) {
6720
+ const value = event.value;
6721
+ if (typeof value?.from === `string`) change.from = value.from;
6722
+ if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
6723
+ if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
6724
+ if (`payload` in (value ?? {})) change.payload = value?.payload;
6725
+ if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
6726
+ if (typeof value?.message_type === `string`) change.message_type = value.message_type;
6727
+ }
6728
+ return { change };
6009
6729
  }
6010
6730
  };
6011
6731
 
@@ -6412,29 +7132,136 @@ function buildElectricProxyTarget(options) {
6412
7132
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6413
7133
  const table = options.incomingUrl.searchParams.get(`table`);
6414
7134
  if (table === `entities`) {
6415
- 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"`);
6416
- applyTenantShapeWhere(target, options.tenantId);
7135
+ 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"`);
7136
+ applyShapeWhere(target, buildReadableEntitiesWhere({
7137
+ tenantId: options.tenantId,
7138
+ principalUrl: options.principalUrl ?? ``,
7139
+ principalKind: options.principalKind ?? ``,
7140
+ permissionBypass: options.permissionBypass
7141
+ }));
6417
7142
  } else if (table === `entity_types`) {
6418
7143
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
6419
- applyTenantShapeWhere(target, options.tenantId);
7144
+ applyShapeWhere(target, buildSpawnableEntityTypesWhere({
7145
+ tenantId: options.tenantId,
7146
+ principalUrl: options.principalUrl ?? ``,
7147
+ principalKind: options.principalKind ?? ``,
7148
+ permissionBypass: options.permissionBypass
7149
+ }));
6420
7150
  } else if (table === `runners`) {
6421
- target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
7151
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
6422
7152
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
7153
+ } else if (table === `users`) {
7154
+ target.searchParams.set(`columns`, `"tenant_id","id","display_name","email","avatar_url","created_at","updated_at"`);
7155
+ applyTenantShapeWhere(target, options.tenantId);
7156
+ } else if (table === `entity_effective_permissions`) {
7157
+ target.searchParams.set(`columns`, `"tenant_id","id","entity_url","source_entity_url","source_grant_id","permission","subject_kind","subject_value","expires_at","created_at"`);
7158
+ applyShapeWhere(target, buildCurrentPrincipalEntityEffectivePermissionsWhere({
7159
+ tenantId: options.tenantId,
7160
+ principalUrl: options.principalUrl ?? ``,
7161
+ principalKind: options.principalKind ?? ``,
7162
+ permissionBypass: options.permissionBypass
7163
+ }));
6423
7164
  } else if (table === `runner_runtime_diagnostics`) {
6424
7165
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
6425
7166
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6426
7167
  } else if (table === `entity_dispatch_state`) {
6427
7168
  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"`);
6428
- applyTenantShapeWhere(target, options.tenantId);
7169
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7170
+ tenantId: options.tenantId,
7171
+ principalUrl: options.principalUrl ?? ``,
7172
+ principalKind: options.principalKind ?? ``,
7173
+ permissionBypass: options.permissionBypass
7174
+ }));
6429
7175
  } else if (table === `wake_notifications`) {
6430
7176
  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"`);
6431
- applyTenantShapeWhere(target, options.tenantId);
7177
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7178
+ tenantId: options.tenantId,
7179
+ principalUrl: options.principalUrl ?? ``,
7180
+ principalKind: options.principalKind ?? ``,
7181
+ permissionBypass: options.permissionBypass
7182
+ }));
6432
7183
  } else if (table === `consumer_claims`) {
6433
7184
  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"`);
6434
- applyTenantShapeWhere(target, options.tenantId);
7185
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7186
+ tenantId: options.tenantId,
7187
+ principalUrl: options.principalUrl ?? ``,
7188
+ principalKind: options.principalKind ?? ``,
7189
+ permissionBypass: options.permissionBypass
7190
+ }));
6435
7191
  }
6436
7192
  return target;
6437
7193
  }
7194
+ function buildReadableEntitiesWhere(options) {
7195
+ const tenant = sqlStringLiteral(options.tenantId);
7196
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7197
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7198
+ const principalKind = sqlStringLiteral(options.principalKind);
7199
+ return [
7200
+ `tenant_id = ${tenant}`,
7201
+ `AND (`,
7202
+ ` created_by = ${principalUrl$1}`,
7203
+ ` OR url IN (`,
7204
+ ` SELECT entity_url`,
7205
+ ` FROM entity_effective_permissions`,
7206
+ ` WHERE tenant_id = ${tenant}`,
7207
+ ` AND permission IN ('read', 'manage')`,
7208
+ ` AND (`,
7209
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7210
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7211
+ ` )`,
7212
+ ` )`,
7213
+ `)`
7214
+ ].join(`\n`);
7215
+ }
7216
+ function buildReadableEntityUrlWhere(options) {
7217
+ const tenant = sqlStringLiteral(options.tenantId);
7218
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7219
+ return [
7220
+ `tenant_id = ${tenant}`,
7221
+ `AND entity_url IN (`,
7222
+ ` SELECT url`,
7223
+ ` FROM entities`,
7224
+ ` WHERE ${indentWhere(buildReadableEntitiesWhere(options), ` `).trimStart()}`,
7225
+ `)`
7226
+ ].join(`\n`);
7227
+ }
7228
+ function buildCurrentPrincipalEntityEffectivePermissionsWhere(options) {
7229
+ const tenant = sqlStringLiteral(options.tenantId);
7230
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7231
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7232
+ const principalKind = sqlStringLiteral(options.principalKind);
7233
+ return [
7234
+ `tenant_id = ${tenant}`,
7235
+ `AND (`,
7236
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7237
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7238
+ `)`,
7239
+ `AND entity_url IN (`,
7240
+ ` SELECT url`,
7241
+ ` FROM entities`,
7242
+ ` WHERE ${buildReadableEntitiesWhere(options)}`,
7243
+ `)`
7244
+ ].join(`\n`);
7245
+ }
7246
+ function buildSpawnableEntityTypesWhere(options) {
7247
+ const tenant = sqlStringLiteral(options.tenantId);
7248
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7249
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7250
+ const principalKind = sqlStringLiteral(options.principalKind);
7251
+ return [
7252
+ `tenant_id = ${tenant}`,
7253
+ `AND name IN (`,
7254
+ ` SELECT entity_type`,
7255
+ ` FROM entity_type_permission_grants`,
7256
+ ` WHERE tenant_id = ${tenant}`,
7257
+ ` AND permission IN ('spawn', 'manage')`,
7258
+ ` AND (`,
7259
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7260
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7261
+ ` )`,
7262
+ `)`
7263
+ ].join(`\n`);
7264
+ }
6438
7265
  async function forwardFetchRequest(options) {
6439
7266
  const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting, options.durableStreamsUrl);
6440
7267
  const routingInput = {
@@ -6469,13 +7296,170 @@ function decodeJsonObject(body) {
6469
7296
  return null;
6470
7297
  }
6471
7298
  function applyTenantShapeWhere(target, tenantId, extraConditions = []) {
6472
- const tenantWhere = [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(` AND `);
7299
+ applyShapeWhere(target, [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(` AND `));
7300
+ }
7301
+ function applyShapeWhere(target, enforcedWhere) {
6473
7302
  const existingWhere = target.searchParams.get(`where`);
6474
- target.searchParams.set(`where`, existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere);
7303
+ target.searchParams.set(`where`, existingWhere ? `${enforcedWhere} AND (${existingWhere})` : enforcedWhere);
6475
7304
  }
6476
7305
  function sqlStringLiteral(value) {
6477
7306
  return `'${value.replace(/'/g, `''`)}'`;
6478
7307
  }
7308
+ function indentWhere(where, prefix) {
7309
+ return where.split(`\n`).map((line) => `${prefix}${line}`).join(`\n`);
7310
+ }
7311
+
7312
+ //#endregion
7313
+ //#region src/permissions.ts
7314
+ const authzDecisionCache = new WeakMap();
7315
+ function principalSubject(principal) {
7316
+ return {
7317
+ principalUrl: principal.url,
7318
+ principalKind: principal.kind
7319
+ };
7320
+ }
7321
+ function isPermissionBypassPrincipal(ctx) {
7322
+ return isBuiltInSystemPrincipalUrl(ctx.principal.url);
7323
+ }
7324
+ async function canAccessEntity(ctx, entity, permission, request) {
7325
+ if (isPermissionBypassPrincipal(ctx)) return true;
7326
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7327
+ const builtInAllowed = entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal));
7328
+ return await applyAuthorizationHook(ctx, {
7329
+ verb: permission,
7330
+ resourceKey: `entity:${entity.url}`,
7331
+ resource: {
7332
+ kind: `entity`,
7333
+ entity
7334
+ },
7335
+ builtInAllowed,
7336
+ request
7337
+ });
7338
+ }
7339
+ async function canAccessEntityType(ctx, entityType, permission, request) {
7340
+ if (isPermissionBypassPrincipal(ctx)) return true;
7341
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7342
+ const builtInAllowed = await ctx.entityManager.registry.hasEntityTypePermission(entityType.name, permission, principalSubject(ctx.principal));
7343
+ return await applyAuthorizationHook(ctx, {
7344
+ verb: permission,
7345
+ resourceKey: `entity_type:${entityType.name}`,
7346
+ resource: {
7347
+ kind: `entity_type`,
7348
+ entityType
7349
+ },
7350
+ builtInAllowed,
7351
+ request
7352
+ });
7353
+ }
7354
+ async function canRegisterEntityType(ctx, input, request) {
7355
+ if (isPermissionBypassPrincipal(ctx)) return true;
7356
+ return await applyAuthorizationHook(ctx, {
7357
+ verb: `manage`,
7358
+ resourceKey: `entity_type_registration:${input.name}`,
7359
+ resource: {
7360
+ kind: `entity_type_registration`,
7361
+ entityTypeName: input.name
7362
+ },
7363
+ builtInAllowed: true,
7364
+ request
7365
+ });
7366
+ }
7367
+ async function canAccessSharedState(ctx, sharedStateId, permission, request, ownerEntityUrl) {
7368
+ if (isPermissionBypassPrincipal(ctx)) return true;
7369
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7370
+ const storedLinkedEntityUrls = await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(sharedStateId);
7371
+ const bootstrapEntityUrls = storedLinkedEntityUrls.length === 0 && ownerEntityUrl ? [ownerEntityUrl] : [];
7372
+ const linkedEntityUrls = [...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls])];
7373
+ for (const entityUrl of linkedEntityUrls) {
7374
+ const entity = await ctx.entityManager.registry.getEntity(entityUrl);
7375
+ if (!entity) continue;
7376
+ if (entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal))) return await applyAuthorizationHook(ctx, {
7377
+ verb: permission,
7378
+ resourceKey: `shared_state:${sharedStateId}`,
7379
+ resource: {
7380
+ kind: `shared_state`,
7381
+ sharedStateId,
7382
+ linkedEntityUrls
7383
+ },
7384
+ builtInAllowed: true,
7385
+ request
7386
+ });
7387
+ }
7388
+ return await applyAuthorizationHook(ctx, {
7389
+ verb: permission,
7390
+ resourceKey: `shared_state:${sharedStateId}`,
7391
+ resource: {
7392
+ kind: `shared_state`,
7393
+ sharedStateId,
7394
+ linkedEntityUrls
7395
+ },
7396
+ builtInAllowed: false,
7397
+ request
7398
+ });
7399
+ }
7400
+ async function applyAuthorizationHook(ctx, input) {
7401
+ const hook = ctx.authorizeRequest;
7402
+ if (!hook) return input.builtInAllowed;
7403
+ const cacheKey = [
7404
+ ctx.service,
7405
+ ctx.principal.url,
7406
+ input.verb,
7407
+ input.resourceKey
7408
+ ].join(`|`);
7409
+ const cached = getCachedDecision(hook, cacheKey);
7410
+ if (cached) return cached.decision === `allow`;
7411
+ let decision;
7412
+ try {
7413
+ decision = await hook({
7414
+ tenant: ctx.service,
7415
+ principal: ctx.principal,
7416
+ verb: input.verb,
7417
+ resource: input.resource,
7418
+ request: input.request ? requestMetadata(input.request) : void 0,
7419
+ builtInAllowed: input.builtInAllowed
7420
+ });
7421
+ } catch (error) {
7422
+ serverLog.warn(`[agent-server] authorization hook failed:`, error);
7423
+ return false;
7424
+ }
7425
+ cacheDecision(hook, cacheKey, decision);
7426
+ return decision.decision === `allow`;
7427
+ }
7428
+ function getCachedDecision(hook, cacheKey) {
7429
+ const cache = authzDecisionCache.get(hook);
7430
+ const entry = cache?.get(cacheKey);
7431
+ if (!entry) return null;
7432
+ if (entry.expiresAt <= Date.now()) {
7433
+ cache?.delete(cacheKey);
7434
+ return null;
7435
+ }
7436
+ return { decision: entry.decision };
7437
+ }
7438
+ function cacheDecision(hook, cacheKey, decision) {
7439
+ if (!decision.expires_at) return;
7440
+ const expiresAt = Date.parse(decision.expires_at);
7441
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
7442
+ let cache = authzDecisionCache.get(hook);
7443
+ if (!cache) {
7444
+ cache = new Map();
7445
+ authzDecisionCache.set(hook, cache);
7446
+ }
7447
+ cache.set(cacheKey, {
7448
+ decision: decision.decision,
7449
+ expiresAt
7450
+ });
7451
+ }
7452
+ function requestMetadata(request) {
7453
+ const headers = {};
7454
+ request.headers.forEach((value, key) => {
7455
+ headers[key] = value;
7456
+ });
7457
+ return {
7458
+ method: request.method,
7459
+ url: request.url,
7460
+ headers
7461
+ };
7462
+ }
6479
7463
 
6480
7464
  //#endregion
6481
7465
  //#region src/webhook-signing.ts
@@ -6567,6 +7551,7 @@ const subscriptionControlActions = [
6567
7551
  `ack`,
6568
7552
  `release`
6569
7553
  ];
7554
+ const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`;
6570
7555
  const durableStreamsRouter = (0, itty_router.Router)();
6571
7556
  durableStreamsRouter.put(`/__ds/subscriptions/:subscriptionId`, putSubscriptionBase);
6572
7557
  durableStreamsRouter.get(`/__ds/subscriptions/:subscriptionId`, getSubscriptionBase);
@@ -6784,6 +7769,8 @@ async function webhookJwks(_request, ctx) {
6784
7769
  });
6785
7770
  }
6786
7771
  async function streamAppend(request, ctx) {
7772
+ const auth = await authorizeDurableStreamAccess(request, ctx);
7773
+ if (auth) return auth;
6787
7774
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
6788
7775
  request: {
6789
7776
  method: req.method,
@@ -6800,8 +7787,9 @@ async function streamAppend(request, ctx) {
6800
7787
  }));
6801
7788
  }
6802
7789
  async function proxyPassThrough(request, ctx) {
7790
+ const auth = await authorizeDurableStreamAccess(request, ctx);
7791
+ if (auth) return auth;
6803
7792
  const streamPath = new URL(request.url).pathname;
6804
- if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
6805
7793
  const upstream = await forwardToDurableStreams(ctx, request);
6806
7794
  const method = request.method.toUpperCase();
6807
7795
  const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
@@ -6812,6 +7800,51 @@ async function proxyPassThrough(request, ctx) {
6812
7800
  await endTrackedRead?.();
6813
7801
  }
6814
7802
  }
7803
+ async function authorizeDurableStreamAccess(request, ctx) {
7804
+ const method = request.method.toUpperCase();
7805
+ const streamPath = new URL(request.url).pathname;
7806
+ if (method === `GET` || method === `HEAD`) {
7807
+ const registry = ctx.entityManager?.registry;
7808
+ const entity = registry?.getEntityByStream ? await registry.getEntityByStream(streamPath) : null;
7809
+ if (entity) {
7810
+ if (await canAccessEntity(ctx, entity, `read`, request)) return void 0;
7811
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${entity.url}`);
7812
+ }
7813
+ const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath);
7814
+ if (attachmentEntityUrl) {
7815
+ const attachmentEntity = registry?.getEntity ? await registry.getEntity(attachmentEntityUrl) : null;
7816
+ if (!attachmentEntity) return apiError(404, ErrCodeNotFound, `Entity not found`);
7817
+ if (await canAccessEntity(ctx, attachmentEntity, `read`, request)) return void 0;
7818
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${attachmentEntity.url}`);
7819
+ }
7820
+ }
7821
+ const sharedStateId = sharedStateIdFromPath(streamPath);
7822
+ if (!sharedStateId) return void 0;
7823
+ if (method === `GET` || method === `HEAD`) {
7824
+ if (await canAccessSharedState(ctx, sharedStateId, `read`, request)) return void 0;
7825
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read shared state`);
7826
+ }
7827
+ if (method === `PUT` || method === `POST`) {
7828
+ const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
7829
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
7830
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
7831
+ }
7832
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
7833
+ }
7834
+ function entityUrlFromAttachmentStreamPath(path$2) {
7835
+ const match = path$2.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/);
7836
+ if (!match) return null;
7837
+ return `/${match[1]}/${match[2]}`;
7838
+ }
7839
+ function sharedStateIdFromPath(path$2) {
7840
+ const match = path$2.match(/^\/_electric\/shared-state\/([^/]+)$/);
7841
+ if (!match) return null;
7842
+ try {
7843
+ return decodeURIComponent(match[1]);
7844
+ } catch {
7845
+ return match[1];
7846
+ }
7847
+ }
6815
7848
 
6816
7849
  //#endregion
6817
7850
  //#region src/routing/electric-proxy-router.ts
@@ -6819,12 +7852,15 @@ const electricProxyRouter = (0, itty_router.Router)({ base: `/_electric/electric
6819
7852
  electricProxyRouter.get(`/*`, proxyElectric);
6820
7853
  async function proxyElectric(request, ctx) {
6821
7854
  if (!ctx.electricUrl) return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`);
7855
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
6822
7856
  const target = buildElectricProxyTarget({
6823
7857
  incomingUrl: new URL(request.url),
6824
7858
  electricUrl: ctx.electricUrl,
6825
7859
  electricSecret: ctx.electricSecret,
6826
7860
  tenantId: ctx.service,
6827
- principalUrl: ctx.principal.url
7861
+ principalUrl: ctx.principal.url,
7862
+ principalKind: ctx.principal.kind,
7863
+ permissionBypass: isPermissionBypassPrincipal(ctx)
6828
7864
  });
6829
7865
  const headers = new Headers(request.headers);
6830
7866
  headers.delete(`host`);
@@ -6844,6 +7880,28 @@ async function proxyElectric(request, ctx) {
6844
7880
  });
6845
7881
  }
6846
7882
 
7883
+ //#endregion
7884
+ //#region src/sandbox-choice-schema.ts
7885
+ /**
7886
+ * Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
7887
+ * the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
7888
+ * persisted on the entity. The matching `SandboxChoice` type is hand-maintained
7889
+ * in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
7890
+ * the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
7891
+ *
7892
+ * Validation happens once, at the router boundary (this schema is embedded in
7893
+ * the spawn body schema); the spawn resolver consumes already-validated input,
7894
+ * so there is intentionally no separate `parse` helper here.
7895
+ */
7896
+ const sandboxChoiceSchema = __sinclair_typebox.Type.Object({
7897
+ profile: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7898
+ key: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7899
+ scope: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`entity`), __sinclair_typebox.Type.Literal(`wake`)])),
7900
+ persistent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
7901
+ owner: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
7902
+ inherit: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
7903
+ });
7904
+
6847
7905
  //#endregion
6848
7906
  //#region src/routing/entities-router.ts
6849
7907
  const stringRecordSchema$1 = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String());
@@ -6861,12 +7919,35 @@ const wakeConditionSchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Ty
6861
7919
  __sinclair_typebox.Type.Literal(`delete`)
6862
7920
  ])))
6863
7921
  })]);
7922
+ const permissionSubjectSchema = __sinclair_typebox.Type.Object({
7923
+ subject_kind: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`principal`), __sinclair_typebox.Type.Literal(`principal_kind`)]),
7924
+ subject_value: __sinclair_typebox.Type.String()
7925
+ }, { additionalProperties: false });
7926
+ const entityPermissionSchema = __sinclair_typebox.Type.Union([
7927
+ __sinclair_typebox.Type.Literal(`read`),
7928
+ __sinclair_typebox.Type.Literal(`write`),
7929
+ __sinclair_typebox.Type.Literal(`delete`),
7930
+ __sinclair_typebox.Type.Literal(`signal`),
7931
+ __sinclair_typebox.Type.Literal(`fork`),
7932
+ __sinclair_typebox.Type.Literal(`schedule`),
7933
+ __sinclair_typebox.Type.Literal(`spawn`),
7934
+ __sinclair_typebox.Type.Literal(`manage`)
7935
+ ]);
7936
+ const entityPermissionGrantInputSchema = __sinclair_typebox.Type.Object({
7937
+ ...permissionSubjectSchema.properties,
7938
+ permission: entityPermissionSchema,
7939
+ propagation: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`self`), __sinclair_typebox.Type.Literal(`descendants`)])),
7940
+ copy_to_children: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
7941
+ expires_at: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
7942
+ }, { additionalProperties: false });
6864
7943
  const spawnBodySchema = __sinclair_typebox.Type.Object({
6865
7944
  args: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown())),
6866
7945
  tags: __sinclair_typebox.Type.Optional(stringRecordSchema$1),
6867
7946
  parent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6868
7947
  dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
7948
+ sandbox: __sinclair_typebox.Type.Optional(sandboxChoiceSchema),
6869
7949
  initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
7950
+ grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(entityPermissionGrantInputSchema)),
6870
7951
  wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
6871
7952
  subscriberUrl: __sinclair_typebox.Type.String(),
6872
7953
  condition: wakeConditionSchema,
@@ -6888,8 +7969,22 @@ const sendBodySchema = __sinclair_typebox.Type.Object({
6888
7969
  ])),
6889
7970
  position: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6890
7971
  afterMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
6891
- from: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
7972
+ from: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7973
+ from_principal: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7974
+ from_agent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
6892
7975
  });
7976
+ function agentUrlForPrincipal(principal) {
7977
+ if (principal.kind === `agent`) return `/${principal.id}`;
7978
+ if (principal.key.startsWith(`entity:`)) return `/${principal.key.slice(`entity:`.length)}`;
7979
+ return null;
7980
+ }
7981
+ function agentUrlPath(value) {
7982
+ try {
7983
+ return new URL(value).pathname;
7984
+ } catch {
7985
+ return value;
7986
+ }
7987
+ }
6893
7988
  const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
6894
7989
  payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
6895
7990
  position: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -6907,7 +8002,11 @@ const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
6907
8002
  });
6908
8003
  const forkBodySchema = __sinclair_typebox.Type.Object({
6909
8004
  instance_id: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
6910
- waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
8005
+ waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
8006
+ fork_pointer: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
8007
+ offset: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Null()]),
8008
+ sub_offset: __sinclair_typebox.Type.Number()
8009
+ }))
6911
8010
  });
6912
8011
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
6913
8012
  const entitySignalSchema = __sinclair_typebox.Type.Union([
@@ -6964,24 +8063,27 @@ const attachmentSubjectTypes = new Set([
6964
8063
  ]);
6965
8064
  const entitiesRouter = (0, itty_router.Router)({ base: `/_electric/entities` });
6966
8065
  entitiesRouter.get(`/`, listEntities);
6967
- entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
6968
- entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6969
- entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6970
- entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6971
- entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6972
- entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6973
- entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
6974
- entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
6975
- entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
6976
- entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6977
- entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
6978
- entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
6979
- entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), setTag);
6980
- entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, deleteTag);
6981
- entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6982
- entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6983
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6984
- entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
8066
+ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), withSpawnPermission, spawnEntity);
8067
+ entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), getEntity);
8068
+ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), headEntity);
8069
+ entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8070
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8071
+ entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8072
+ entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8073
+ entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8074
+ entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
8075
+ entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), withEntityPermission(`write`), updateInboxMessage);
8076
+ entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withEntityPermission(`write`), deleteInboxMessage);
8077
+ entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), withEntityPermission(`fork`), forkEntity);
8078
+ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), withEntityPermission(`write`), setTag);
8079
+ entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
8080
+ entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
8081
+ entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
8082
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
8083
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
8084
+ entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
8085
+ entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
8086
+ entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
6985
8087
  function entityUrlFromSegments(type, instanceId) {
6986
8088
  if (!type || !instanceId) return null;
6987
8089
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -7080,6 +8182,17 @@ function rejectPrincipalEntityMutation(request, action) {
7080
8182
  if (entity.type !== `principal`) return void 0;
7081
8183
  return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be ${action}`);
7082
8184
  }
8185
+ function parseExpiresAt$1(value) {
8186
+ if (value === void 0) return void 0;
8187
+ const expiresAt = new Date(value);
8188
+ if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8189
+ return expiresAt;
8190
+ }
8191
+ function parseGrantId$1(request) {
8192
+ const grantId = Number.parseInt(String(request.params.grantId), 10);
8193
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
8194
+ return grantId;
8195
+ }
7083
8196
  async function withExistingEntity(request, ctx) {
7084
8197
  const entityUrl = entityUrlFromSegments(request.params.type, request.params.instanceId);
7085
8198
  if (!entityUrl) return void 0;
@@ -7110,17 +8223,76 @@ async function withSpawnableEntityType(request, ctx) {
7110
8223
  if (request.params.type === `principal`) return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be spawned directly`);
7111
8224
  const entityType = await ctx.entityManager.registry.getEntityType(request.params.type);
7112
8225
  if (!entityType) return apiError(404, ErrCodeUnknownEntityType, `Entity type "${request.params.type}" not found`);
8226
+ request.spawnRoute = { entityType };
7113
8227
  return void 0;
7114
8228
  }
8229
+ function withEntityPermission(permission) {
8230
+ return async (request, ctx) => {
8231
+ const { entity } = requireExistingEntityRoute(request);
8232
+ if (await canAccessEntity(ctx, entity, permission, request)) return void 0;
8233
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to ${permission} ${entity.url}`);
8234
+ };
8235
+ }
8236
+ async function withSpawnPermission(request, ctx) {
8237
+ const parsed = routeBody(request);
8238
+ const entityType = request.spawnRoute?.entityType;
8239
+ if (!entityType) throw new Error(`spawnable entity type middleware did not run`);
8240
+ if (!await canAccessEntityType(ctx, entityType, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
8241
+ if (!parsed.parent) return void 0;
8242
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
8243
+ if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
8244
+ if (await canAccessEntity(ctx, parent, `spawn`, request)) return await validateParentedSpawnGrants(request, ctx, parent, parsed);
8245
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
8246
+ }
8247
+ async function validateParentedSpawnGrants(request, ctx, parent, parsed) {
8248
+ const needsParentManage = (parsed.grants ?? []).some(requiresParentManageForInitialGrant);
8249
+ if (!needsParentManage) return void 0;
8250
+ if (await canAccessEntity(ctx, parent, `manage`, request)) return void 0;
8251
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to delegate broad grants from ${parent.url}`);
8252
+ }
8253
+ function requiresParentManageForInitialGrant(grant) {
8254
+ return grant.permission === `manage` || grant.subject_kind === `principal_kind` || grant.propagation === `descendants` || grant.copy_to_children === true;
8255
+ }
7115
8256
  async function listEntities({ query }, ctx) {
7116
8257
  const { entities: entities$1 } = await ctx.entityManager.registry.listEntities({
7117
8258
  type: firstQueryValue$1(query.type),
7118
8259
  status: firstQueryValue$1(query.status),
7119
8260
  parent: firstQueryValue$1(query.parent),
7120
- created_by: firstQueryValue$1(query.created_by)
8261
+ created_by: firstQueryValue$1(query.created_by),
8262
+ readableBy: {
8263
+ ...principalSubject(ctx.principal),
8264
+ bypass: isPermissionBypassPrincipal(ctx)
8265
+ }
7121
8266
  });
7122
8267
  return (0, itty_router.json)(entities$1.map((entity) => toPublicEntity(entity)));
7123
8268
  }
8269
+ async function listEntityPermissionGrants(request, ctx) {
8270
+ const { entityUrl } = requireExistingEntityRoute(request);
8271
+ const grants = await ctx.entityManager.registry.listEntityPermissionGrants(entityUrl);
8272
+ return (0, itty_router.json)({ grants });
8273
+ }
8274
+ async function createEntityPermissionGrant(request, ctx) {
8275
+ const { entityUrl } = requireExistingEntityRoute(request);
8276
+ const parsed = routeBody(request);
8277
+ const grant = await ctx.entityManager.registry.createEntityPermissionGrant({
8278
+ entityUrl,
8279
+ permission: parsed.permission,
8280
+ subjectKind: parsed.subject_kind,
8281
+ subjectValue: parsed.subject_value,
8282
+ propagation: parsed.propagation,
8283
+ copyToChildren: parsed.copy_to_children,
8284
+ expiresAt: parseExpiresAt$1(parsed.expires_at),
8285
+ createdBy: ctx.principal.url
8286
+ });
8287
+ await ctx.entityBridgeManager.onEntityChanged(entityUrl);
8288
+ return (0, itty_router.json)(grant, { status: 201 });
8289
+ }
8290
+ async function deleteEntityPermissionGrant(request, ctx) {
8291
+ const { entityUrl } = requireExistingEntityRoute(request);
8292
+ const deleted = await ctx.entityManager.registry.deleteEntityPermissionGrant(entityUrl, parseGrantId$1(request));
8293
+ if (deleted) await ctx.entityBridgeManager.onEntityChanged(entityUrl);
8294
+ return deleted ? (0, itty_router.status)(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
8295
+ }
7124
8296
  async function upsertSchedule(request, ctx) {
7125
8297
  const principalMutationError = rejectPrincipalEntityMutation(request, `scheduled`);
7126
8298
  if (principalMutationError) return principalMutationError;
@@ -7225,7 +8397,12 @@ async function forkEntity(request, ctx) {
7225
8397
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7226
8398
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7227
8399
  rootInstanceId: parsed.instance_id,
7228
- waitTimeoutMs: parsed.waitTimeoutMs
8400
+ waitTimeoutMs: parsed.waitTimeoutMs,
8401
+ createdBy: ctx.principal.url,
8402
+ ...parsed.fork_pointer && { forkPointer: {
8403
+ offset: parsed.fork_pointer.offset,
8404
+ subOffset: parsed.fork_pointer.sub_offset
8405
+ } }
7229
8406
  });
7230
8407
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7231
8408
  return (0, itty_router.json)({
@@ -7237,26 +8414,27 @@ async function sendEntity(request, ctx) {
7237
8414
  const parsed = routeBody(request);
7238
8415
  const principal = ctx.principal;
7239
8416
  if (parsed.from !== void 0 && parsed.from !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from must match Electric-Principal`);
8417
+ if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
8418
+ if (parsed.from_agent !== void 0) {
8419
+ const principalAgentUrl = agentUrlForPrincipal(principal);
8420
+ if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8421
+ }
7240
8422
  await ctx.entityManager.ensurePrincipal(principal);
7241
8423
  const { entityUrl, entity } = requireExistingEntityRoute(request);
7242
8424
  const dispatchEntity = entity.dispatch_policy ? entity : await backfillEntityDispatchPolicy(ctx, entity);
7243
8425
  await linkEntityDispatchSubscription(ctx, dispatchEntity);
7244
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, {
7245
- from: principal.url,
7246
- payload: parsed.payload,
7247
- key: parsed.key,
7248
- type: parsed.type,
7249
- mode: parsed.mode,
7250
- position: parsed.position
7251
- }, new Date(Date.now() + parsed.afterMs));
7252
- else await ctx.entityManager.send(entityUrl, {
8426
+ const sendReq = {
7253
8427
  from: principal.url,
8428
+ from_principal: principal.url,
8429
+ from_agent: parsed.from_agent,
7254
8430
  payload: parsed.payload,
7255
8431
  key: parsed.key,
7256
8432
  type: parsed.type,
7257
8433
  mode: parsed.mode,
7258
8434
  position: parsed.position
7259
- });
8435
+ };
8436
+ if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
8437
+ else await ctx.entityManager.send(entityUrl, sendReq);
7260
8438
  return (0, itty_router.status)(204);
7261
8439
  }
7262
8440
  async function createAttachment(request, ctx) {
@@ -7323,10 +8501,22 @@ async function spawnEntity(request, ctx) {
7323
8501
  tags: parsed.tags,
7324
8502
  parent: parsed.parent,
7325
8503
  dispatch_policy: dispatchPolicy,
8504
+ sandbox: parsed.sandbox,
7326
8505
  initialMessage: void 0,
7327
8506
  wake: parsed.wake,
7328
8507
  created_by: principal.url
7329
8508
  });
8509
+ if (parsed.parent) await ctx.entityManager.registry.copyEntityPermissionGrantsForSpawn(parsed.parent, entity.url, principal.url);
8510
+ for (const grant of parsed.grants ?? []) await ctx.entityManager.registry.createEntityPermissionGrant({
8511
+ entityUrl: entity.url,
8512
+ permission: grant.permission,
8513
+ subjectKind: grant.subject_kind,
8514
+ subjectValue: grant.subject_value,
8515
+ propagation: grant.propagation,
8516
+ copyToChildren: grant.copy_to_children,
8517
+ expiresAt: parseExpiresAt$1(grant.expires_at),
8518
+ createdBy: principal.url
8519
+ });
7330
8520
  const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
7331
8521
  if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
7332
8522
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
@@ -7378,6 +8568,12 @@ async function signalEntity(request, ctx) {
7378
8568
  //#region src/routing/entity-types-router.ts
7379
8569
  const jsonObjectSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Unknown());
7380
8570
  const schemaMapSchema = __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), jsonObjectSchema);
8571
+ const typePermissionGrantInputSchema = __sinclair_typebox.Type.Object({
8572
+ subject_kind: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`principal`), __sinclair_typebox.Type.Literal(`principal_kind`)]),
8573
+ subject_value: __sinclair_typebox.Type.String(),
8574
+ permission: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`spawn`), __sinclair_typebox.Type.Literal(`manage`)]),
8575
+ expires_at: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
8576
+ }, { additionalProperties: false });
7381
8577
  const registerEntityTypeBodySchema = __sinclair_typebox.Type.Object({
7382
8578
  name: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7383
8579
  description: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -7385,7 +8581,8 @@ const registerEntityTypeBodySchema = __sinclair_typebox.Type.Object({
7385
8581
  inbox_schemas: __sinclair_typebox.Type.Optional(schemaMapSchema),
7386
8582
  state_schemas: __sinclair_typebox.Type.Optional(schemaMapSchema),
7387
8583
  serve_endpoint: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
7388
- default_dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema)
8584
+ default_dispatch_policy: __sinclair_typebox.Type.Optional(dispatchPolicySchema),
8585
+ permission_grants: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(typePermissionGrantInputSchema))
7389
8586
  }, { additionalProperties: false });
7390
8587
  const amendEntityTypeSchemasBodySchema = __sinclair_typebox.Type.Object({
7391
8588
  inbox_schemas: __sinclair_typebox.Type.Optional(schemaMapSchema),
@@ -7393,20 +8590,56 @@ const amendEntityTypeSchemasBodySchema = __sinclair_typebox.Type.Object({
7393
8590
  }, { additionalProperties: false });
7394
8591
  const entityTypesRouter = (0, itty_router.Router)({ base: `/_electric/entity-types` });
7395
8592
  entityTypesRouter.get(`/`, listEntityTypes);
7396
- entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), registerEntityType);
7397
- entityTypesRouter.patch(`/:name/schemas`, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
7398
- entityTypesRouter.get(`/:name`, getEntityType);
7399
- entityTypesRouter.delete(`/:name`, deleteEntityType);
8593
+ entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), withEntityTypeRegistrationPermission, registerEntityType);
8594
+ entityTypesRouter.patch(`/:name/schemas`, withExistingEntityType, withEntityTypeManagePermission, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
8595
+ entityTypesRouter.get(`/:name`, withExistingEntityType, withEntityTypeSpawnPermission, getEntityType);
8596
+ entityTypesRouter.delete(`/:name`, withExistingEntityType, withEntityTypeManagePermission, deleteEntityType);
8597
+ entityTypesRouter.get(`/:name/grants`, withExistingEntityType, withEntityTypeManagePermission, listTypePermissionGrants);
8598
+ entityTypesRouter.post(`/:name/grants`, withExistingEntityType, withSchema(typePermissionGrantInputSchema), withEntityTypeManagePermission, createTypePermissionGrant);
8599
+ entityTypesRouter.delete(`/:name/grants/:grantId`, withExistingEntityType, withEntityTypeManagePermission, deleteTypePermissionGrant);
7400
8600
  async function registerEntityType(request, ctx) {
7401
8601
  const parsed = routeBody(request);
7402
8602
  const normalized = normalizeEntityTypeRequest(parsed);
7403
8603
  if (normalized.serve_endpoint && !normalized.description && !normalized.creation_schema) return await discoverServeEndpoint(ctx, normalized);
7404
8604
  const entityType = await ctx.entityManager.registerEntityType(normalized);
8605
+ await applyRegistrationPermissionGrants(ctx, entityType.name, normalized);
7405
8606
  return (0, itty_router.json)(toPublicEntityType(entityType), { status: 201 });
7406
8607
  }
7407
8608
  async function listEntityTypes(_request, ctx) {
7408
8609
  const entityTypes$1 = await ctx.entityManager.registry.listEntityTypes();
7409
- return (0, itty_router.json)(entityTypes$1.map((entityType) => toPublicEntityType(entityType)));
8610
+ const visible = [];
8611
+ for (const entityType of entityTypes$1) if (await canAccessEntityType(ctx, entityType, `spawn`)) visible.push(entityType);
8612
+ return (0, itty_router.json)(visible.map((entityType) => toPublicEntityType(entityType)));
8613
+ }
8614
+ async function withExistingEntityType(request, ctx) {
8615
+ const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
8616
+ if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
8617
+ request.entityTypeRoute = { entityType };
8618
+ return void 0;
8619
+ }
8620
+ async function withEntityTypeManagePermission(request, ctx) {
8621
+ const entityType = request.entityTypeRoute?.entityType;
8622
+ if (!entityType) throw new Error(`entity type middleware did not run`);
8623
+ if (await canAccessEntityType(ctx, entityType, `manage`, request)) return void 0;
8624
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${entityType.name}`);
8625
+ }
8626
+ async function withEntityTypeSpawnPermission(request, ctx) {
8627
+ const entityType = request.entityTypeRoute?.entityType;
8628
+ if (!entityType) throw new Error(`entity type middleware did not run`);
8629
+ if (await canAccessEntityType(ctx, entityType, `spawn`, request)) return void 0;
8630
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
8631
+ }
8632
+ async function withEntityTypeRegistrationPermission(request, ctx) {
8633
+ const parsed = normalizeEntityTypeRequest(routeBody(request));
8634
+ if (!parsed.name) return void 0;
8635
+ const existing = await ctx.entityManager.registry.getEntityType(parsed.name);
8636
+ if (existing) {
8637
+ request.entityTypeRoute = { entityType: existing };
8638
+ if (await canAccessEntityType(ctx, existing, `manage`, request)) return void 0;
8639
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${existing.name}`);
8640
+ }
8641
+ if (await canRegisterEntityType(ctx, parsed, request)) return void 0;
8642
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to register entity types`);
7410
8643
  }
7411
8644
  async function discoverServeEndpoint(ctx, parsed) {
7412
8645
  try {
@@ -7415,17 +8648,17 @@ async function discoverServeEndpoint(ctx, parsed) {
7415
8648
  const manifest = await response.json();
7416
8649
  if (manifest.name !== parsed.name) return apiError(400, ErrCodeServeEndpointNameMismatch, `Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`);
7417
8650
  manifest.serve_endpoint = parsed.serve_endpoint;
8651
+ manifest.permission_grants = parsed.permission_grants;
7418
8652
  const entityType = await ctx.entityManager.registerEntityType(normalizeEntityTypeRequest(manifest));
8653
+ await applyRegistrationPermissionGrants(ctx, entityType.name, manifest);
7419
8654
  return (0, itty_router.json)(toPublicEntityType(entityType), { status: 201 });
7420
8655
  } catch (err) {
7421
8656
  if (err instanceof ElectricAgentsError) throw err;
7422
8657
  return apiError(502, ErrCodeServeEndpointUnreachable, `Failed to reach serve endpoint: ${err instanceof Error ? err.message : String(err)}`);
7423
8658
  }
7424
8659
  }
7425
- async function getEntityType(request, ctx) {
7426
- const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
7427
- if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
7428
- return (0, itty_router.json)(toPublicEntityType(entityType));
8660
+ async function getEntityType(request) {
8661
+ return (0, itty_router.json)(toPublicEntityType(request.entityTypeRoute.entityType));
7429
8662
  }
7430
8663
  async function amendSchemas(request, ctx) {
7431
8664
  const parsed = routeBody(request);
@@ -7439,6 +8672,47 @@ async function deleteEntityType(request, ctx) {
7439
8672
  await ctx.entityManager.deleteEntityType(request.params.name);
7440
8673
  return (0, itty_router.status)(204);
7441
8674
  }
8675
+ async function listTypePermissionGrants(request, ctx) {
8676
+ const grants = await ctx.entityManager.registry.listEntityTypePermissionGrants(request.entityTypeRoute.entityType.name);
8677
+ return (0, itty_router.json)({ grants });
8678
+ }
8679
+ async function createTypePermissionGrant(request, ctx) {
8680
+ const parsed = routeBody(request);
8681
+ const grant = await ctx.entityManager.registry.createEntityTypePermissionGrant({
8682
+ entityType: request.entityTypeRoute.entityType.name,
8683
+ permission: parsed.permission,
8684
+ subjectKind: parsed.subject_kind,
8685
+ subjectValue: parsed.subject_value,
8686
+ expiresAt: parseExpiresAt(parsed.expires_at),
8687
+ createdBy: ctx.principal.url
8688
+ });
8689
+ return (0, itty_router.json)(grant, { status: 201 });
8690
+ }
8691
+ async function deleteTypePermissionGrant(request, ctx) {
8692
+ const deleted = await ctx.entityManager.registry.deleteEntityTypePermissionGrant(request.entityTypeRoute.entityType.name, parseGrantId(request));
8693
+ return deleted ? (0, itty_router.status)(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
8694
+ }
8695
+ async function applyRegistrationPermissionGrants(ctx, entityType, request) {
8696
+ for (const grant of request.permission_grants ?? []) await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
8697
+ entityType,
8698
+ permission: grant.permission,
8699
+ subjectKind: grant.subject_kind,
8700
+ subjectValue: grant.subject_value,
8701
+ expiresAt: parseExpiresAt(grant.expires_at),
8702
+ createdBy: ctx.principal.url
8703
+ });
8704
+ }
8705
+ function parseGrantId(request) {
8706
+ const grantId = Number.parseInt(String(request.params.grantId), 10);
8707
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
8708
+ return grantId;
8709
+ }
8710
+ function parseExpiresAt(value) {
8711
+ if (value === void 0) return void 0;
8712
+ const expiresAt = new Date(value);
8713
+ if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8714
+ return expiresAt;
8715
+ }
7442
8716
  function normalizeEntityTypeRequest(parsed) {
7443
8717
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
7444
8718
  return {
@@ -7451,7 +8725,8 @@ function normalizeEntityTypeRequest(parsed) {
7451
8725
  default_dispatch_policy: parsed.default_dispatch_policy ?? (serveEndpoint ? { targets: [{
7452
8726
  type: `webhook`,
7453
8727
  url: serveEndpoint
7454
- }] } : void 0)
8728
+ }] } : void 0),
8729
+ permission_grants: parsed.permission_grants
7455
8730
  };
7456
8731
  }
7457
8732
  function toPublicEntityType(entityType) {
@@ -7510,6 +8785,7 @@ function applyCors(response) {
7510
8785
  `content-type`,
7511
8786
  `authorization`,
7512
8787
  `electric-claim-token`,
8788
+ `electric-owner-entity`,
7513
8789
  ELECTRIC_PRINCIPAL_HEADER,
7514
8790
  `ngrok-skip-browser-warning`
7515
8791
  ].join(`, `));
@@ -7560,7 +8836,7 @@ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMemb
7560
8836
  observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
7561
8837
  async function ensureEntitiesMembershipStream(request, ctx) {
7562
8838
  const parsed = routeBody(request);
7563
- const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {});
8839
+ const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
7564
8840
  return (0, itty_router.json)(result);
7565
8841
  }
7566
8842
  async function ensureCronStream(request, ctx) {
@@ -7577,6 +8853,12 @@ function withLeadingSlash(path$2) {
7577
8853
 
7578
8854
  //#endregion
7579
8855
  //#region src/routing/runners-router.ts
8856
+ const sandboxProfileBodySchema = __sinclair_typebox.Type.Object({
8857
+ name: __sinclair_typebox.Type.String(),
8858
+ label: __sinclair_typebox.Type.String(),
8859
+ description: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8860
+ remote: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean())
8861
+ });
7580
8862
  const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7581
8863
  id: __sinclair_typebox.Type.String(),
7582
8864
  owner_principal: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -7589,7 +8871,8 @@ const registerRunnerBodySchema = __sinclair_typebox.Type.Object({
7589
8871
  __sinclair_typebox.Type.Literal(`server`)
7590
8872
  ])),
7591
8873
  admin_status: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`enabled`), __sinclair_typebox.Type.Literal(`disabled`)])),
7592
- wake_stream: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
8874
+ wake_stream: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8875
+ sandbox_profiles: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(sandboxProfileBodySchema))
7593
8876
  });
7594
8877
  const heartbeatBodySchema = __sinclair_typebox.Type.Object({
7595
8878
  lease_ms: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
@@ -7687,7 +8970,8 @@ async function registerRunner(request, ctx) {
7687
8970
  label: parsed.label,
7688
8971
  kind: parsed.kind,
7689
8972
  adminStatus: parsed.admin_status,
7690
- wakeStream: parsed.wake_stream
8973
+ wakeStream: parsed.wake_stream,
8974
+ sandboxProfiles: parsed.sandbox_profiles
7691
8975
  });
7692
8976
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7693
8977
  return (0, itty_router.json)(runner, { status: 201 });
@@ -7917,6 +9201,7 @@ async function notificationFromClaim(ctx, input) {
7917
9201
  streams: entity.streams,
7918
9202
  tags: entity.tags,
7919
9203
  spawnArgs: entity.spawn_args,
9204
+ sandbox: entity.sandbox,
7920
9205
  createdBy: entity.created_by
7921
9206
  },
7922
9207
  principal: principalFromCreatedBy(entity.created_by)