@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.js CHANGED
@@ -5,15 +5,15 @@ import { fileURLToPath } from "node:url";
5
5
  import { drizzle } from "drizzle-orm/postgres-js";
6
6
  import { migrate } from "drizzle-orm/postgres-js/migrator";
7
7
  import postgres from "postgres";
8
- import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
8
+ import { and, desc, eq, inArray, lt, ne, sql } from "drizzle-orm";
9
9
  import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
10
10
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
11
- import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
11
+ import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, hashString, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
15
- import fastq from "fastq";
16
15
  import { Type } from "@sinclair/typebox";
16
+ import fastq from "fastq";
17
17
  import Ajv from "ajv";
18
18
  import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
19
19
  import { AutoRouter, Router, json, status, withParams } from "itty-router";
@@ -26,11 +26,16 @@ __export(schema_exports, {
26
26
  entities: () => entities,
27
27
  entityBridges: () => entityBridges,
28
28
  entityDispatchState: () => entityDispatchState,
29
+ entityEffectivePermissions: () => entityEffectivePermissions,
30
+ entityLineage: () => entityLineage,
29
31
  entityManifestSources: () => entityManifestSources,
32
+ entityPermissionGrants: () => entityPermissionGrants,
33
+ entityTypePermissionGrants: () => entityTypePermissionGrants,
30
34
  entityTypes: () => entityTypes,
31
35
  runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
32
36
  runners: () => runners,
33
37
  scheduledTasks: () => scheduledTasks,
38
+ sharedStateLinks: () => sharedStateLinks,
34
39
  subscriptionWebhooks: () => subscriptionWebhooks,
35
40
  tagStreamOutbox: () => tagStreamOutbox,
36
41
  users: () => users,
@@ -61,6 +66,7 @@ const entities = pgTable(`entities`, {
61
66
  tags: jsonb(`tags`).notNull().default({}),
62
67
  tagsIndex: text(`tags_index`).array().notNull().default(sql`'{}'::text[]`),
63
68
  spawnArgs: jsonb(`spawn_args`).default({}),
69
+ sandbox: jsonb(`sandbox`),
64
70
  parent: text(`parent`),
65
71
  createdBy: text(`created_by`),
66
72
  typeRevision: integer(`type_revision`),
@@ -77,6 +83,94 @@ const entities = pgTable(`entities`, {
77
83
  index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
78
84
  check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
79
85
  ]);
86
+ const entityTypePermissionGrants = pgTable(`entity_type_permission_grants`, {
87
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
88
+ tenantId: text(`tenant_id`).notNull().default(`default`),
89
+ entityType: text(`entity_type`).notNull(),
90
+ permission: text(`permission`).notNull(),
91
+ subjectKind: text(`subject_kind`).notNull(),
92
+ subjectValue: text(`subject_value`).notNull(),
93
+ createdBy: text(`created_by`),
94
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
95
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
96
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
97
+ }, (table) => [
98
+ index(`idx_type_permission_grants_lookup`).on(table.tenantId, table.entityType, table.permission, table.subjectKind, table.subjectValue),
99
+ index(`idx_type_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
100
+ check(`chk_type_permission_grants_permission`, sql`${table.permission} IN ('spawn', 'manage')`),
101
+ check(`chk_type_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
102
+ ]);
103
+ const entityLineage = pgTable(`entity_lineage`, {
104
+ tenantId: text(`tenant_id`).notNull().default(`default`),
105
+ ancestorUrl: text(`ancestor_url`).notNull(),
106
+ descendantUrl: text(`descendant_url`).notNull(),
107
+ depth: integer(`depth`).notNull(),
108
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
109
+ }, (table) => [
110
+ primaryKey({ columns: [
111
+ table.tenantId,
112
+ table.ancestorUrl,
113
+ table.descendantUrl
114
+ ] }),
115
+ index(`idx_entity_lineage_descendant`).on(table.tenantId, table.descendantUrl),
116
+ check(`chk_entity_lineage_depth`, sql`${table.depth} >= 0`)
117
+ ]);
118
+ const entityPermissionGrants = pgTable(`entity_permission_grants`, {
119
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
120
+ tenantId: text(`tenant_id`).notNull().default(`default`),
121
+ entityUrl: text(`entity_url`).notNull(),
122
+ permission: text(`permission`).notNull(),
123
+ subjectKind: text(`subject_kind`).notNull(),
124
+ subjectValue: text(`subject_value`).notNull(),
125
+ propagation: text(`propagation`).notNull().default(`self`),
126
+ copyToChildren: boolean(`copy_to_children`).notNull().default(false),
127
+ createdBy: text(`created_by`),
128
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
129
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
130
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
131
+ }, (table) => [
132
+ index(`idx_entity_permission_grants_entity`).on(table.tenantId, table.entityUrl),
133
+ index(`idx_entity_permission_grants_subject`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue),
134
+ index(`idx_entity_permission_grants_expiry`).on(table.tenantId, table.expiresAt),
135
+ check(`chk_entity_permission_grants_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
136
+ check(`chk_entity_permission_grants_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`),
137
+ check(`chk_entity_permission_grants_propagation`, sql`${table.propagation} IN ('self', 'descendants')`)
138
+ ]);
139
+ const entityEffectivePermissions = pgTable(`entity_effective_permissions`, {
140
+ id: bigserial(`id`, { mode: `number` }).primaryKey(),
141
+ tenantId: text(`tenant_id`).notNull().default(`default`),
142
+ entityUrl: text(`entity_url`).notNull(),
143
+ sourceEntityUrl: text(`source_entity_url`).notNull(),
144
+ sourceGrantId: bigint(`source_grant_id`, { mode: `number` }).notNull(),
145
+ permission: text(`permission`).notNull(),
146
+ subjectKind: text(`subject_kind`).notNull(),
147
+ subjectValue: text(`subject_value`).notNull(),
148
+ expiresAt: timestamp(`expires_at`, { withTimezone: true }),
149
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow()
150
+ }, (table) => [
151
+ unique(`uq_entity_effective_permission`).on(table.tenantId, table.entityUrl, table.sourceGrantId),
152
+ index(`idx_entity_effective_permissions_lookup`).on(table.tenantId, table.permission, table.subjectKind, table.subjectValue, table.entityUrl),
153
+ index(`idx_entity_effective_permissions_entity`).on(table.tenantId, table.entityUrl),
154
+ index(`idx_entity_effective_permissions_expiry`).on(table.tenantId, table.expiresAt),
155
+ check(`chk_entity_effective_permissions_permission`, sql`${table.permission} IN ('read', 'write', 'delete', 'signal', 'fork', 'schedule', 'spawn', 'manage')`),
156
+ check(`chk_entity_effective_permissions_subject_kind`, sql`${table.subjectKind} IN ('principal', 'principal_kind')`)
157
+ ]);
158
+ const sharedStateLinks = pgTable(`shared_state_links`, {
159
+ tenantId: text(`tenant_id`).notNull().default(`default`),
160
+ sharedStateId: text(`shared_state_id`).notNull(),
161
+ ownerEntityUrl: text(`owner_entity_url`).notNull(),
162
+ manifestKey: text(`manifest_key`).notNull(),
163
+ createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
164
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
165
+ }, (table) => [
166
+ primaryKey({ columns: [
167
+ table.tenantId,
168
+ table.ownerEntityUrl,
169
+ table.manifestKey
170
+ ] }),
171
+ index(`idx_shared_state_links_shared_state`).on(table.tenantId, table.sharedStateId),
172
+ index(`idx_shared_state_links_owner`).on(table.tenantId, table.ownerEntityUrl)
173
+ ]);
80
174
  const users = pgTable(`users`, {
81
175
  tenantId: text(`tenant_id`).notNull().default(`default`),
82
176
  id: text(`id`).notNull(),
@@ -102,6 +196,7 @@ const runners = pgTable(`runners`, {
102
196
  kind: text(`kind`).notNull().default(`local`),
103
197
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
104
198
  wakeStream: text(`wake_stream`).notNull(),
199
+ sandboxProfiles: jsonb(`sandbox_profiles`).notNull().default([]),
105
200
  createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
106
201
  updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
107
202
  }, (table) => [
@@ -262,12 +357,18 @@ const entityBridges = pgTable(`entity_bridges`, {
262
357
  sourceRef: text(`source_ref`).notNull(),
263
358
  tags: jsonb(`tags`).notNull(),
264
359
  streamUrl: text(`stream_url`).notNull(),
360
+ principalUrl: text(`principal_url`),
361
+ principalKind: text(`principal_kind`),
265
362
  shapeHandle: text(`shape_handle`),
266
363
  shapeOffset: text(`shape_offset`),
267
364
  lastObserverActivityAt: timestamp(`last_observer_activity_at`, { withTimezone: true }).notNull().defaultNow(),
268
365
  createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
269
366
  updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
270
- }, (table) => [primaryKey({ columns: [table.tenantId, table.sourceRef] }), unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl)]);
367
+ }, (table) => [
368
+ primaryKey({ columns: [table.tenantId, table.sourceRef] }),
369
+ unique(`uq_entity_bridges_stream_url`).on(table.tenantId, table.streamUrl),
370
+ index(`idx_entity_bridges_principal`).on(table.tenantId, table.principalKind, table.principalUrl)
371
+ ]);
271
372
  const entityManifestSources = pgTable(`entity_manifest_sources`, {
272
373
  tenantId: text(`tenant_id`).notNull().default(`default`),
273
374
  ownerEntityUrl: text(`owner_entity_url`).notNull(),
@@ -399,6 +500,7 @@ function toPublicEntity(entity) {
399
500
  dispatch_policy: entity.dispatch_policy,
400
501
  tags: entity.tags,
401
502
  spawn_args: entity.spawn_args,
503
+ sandbox: entity.sandbox,
402
504
  parent: entity.parent,
403
505
  created_by: entity.created_by,
404
506
  created_at: entity.created_at,
@@ -454,19 +556,35 @@ function isDuplicateUrlError(err) {
454
556
  return e.code === `23505`;
455
557
  }
456
558
  const DEFAULT_RUNNER_LEASE_MS = 3e4;
559
+ const PERMISSION_PRUNE_INTERVAL_MS = 3e4;
457
560
  function runnerWakeStream(runnerId) {
458
561
  return `/runners/${runnerId}/wake`;
459
562
  }
460
563
  var PostgresRegistry = class {
564
+ lastPermissionPruneStartedAt = 0;
565
+ permissionPrunePromise = null;
461
566
  constructor(db, tenantId = DEFAULT_TENANT_ID) {
462
567
  this.db = db;
463
568
  this.tenantId = tenantId;
464
569
  }
465
570
  async initialize() {}
466
571
  close() {}
572
+ async ensureUserForPrincipal(principal) {
573
+ if (principal.kind !== `user`) return;
574
+ await this.db.insert(users).values({
575
+ tenantId: this.tenantId,
576
+ id: principal.id
577
+ }).onConflictDoNothing();
578
+ }
467
579
  async createRunner(input) {
468
580
  const now = new Date();
469
581
  const wakeStream = input.wakeStream ?? runnerWakeStream(input.id);
582
+ const sandboxProfilesValue = input.sandboxProfiles ? input.sandboxProfiles.map((p) => ({
583
+ name: p.name,
584
+ label: p.label,
585
+ ...p.description !== void 0 && { description: p.description },
586
+ ...p.remote !== void 0 && { remote: p.remote }
587
+ })) : void 0;
470
588
  await this.db.insert(runners).values({
471
589
  tenantId: this.tenantId,
472
590
  id: input.id,
@@ -475,6 +593,7 @@ var PostgresRegistry = class {
475
593
  kind: input.kind ?? `local`,
476
594
  adminStatus: input.adminStatus ?? `enabled`,
477
595
  wakeStream,
596
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
478
597
  updatedAt: now
479
598
  }).onConflictDoUpdate({
480
599
  target: [runners.tenantId, runners.id],
@@ -484,6 +603,7 @@ var PostgresRegistry = class {
484
603
  kind: input.kind ?? `local`,
485
604
  adminStatus: input.adminStatus ?? `enabled`,
486
605
  wakeStream,
606
+ ...sandboxProfilesValue !== void 0 && { sandboxProfiles: sandboxProfilesValue },
487
607
  updatedAt: now
488
608
  }
489
609
  });
@@ -491,6 +611,30 @@ var PostgresRegistry = class {
491
611
  if (!runner) throw new Error(`Failed to read back runner "${input.id}"`);
492
612
  return runner;
493
613
  }
614
+ /**
615
+ * Every sandbox profile advertised by a runner in this tenant (one entry
616
+ * per runner that advertises it — names may repeat across runners). Used by
617
+ * spawn validation for unpinned dispatch to learn whether a chosen profile
618
+ * is remote (so a shared sandbox can skip the single-runner guard).
619
+ */
620
+ async listSandboxProfiles() {
621
+ const rows = await this.db.select({ sandboxProfiles: runners.sandboxProfiles }).from(runners).where(eq(runners.tenantId, this.tenantId));
622
+ const profiles = [];
623
+ for (const row of rows) {
624
+ const list = row.sandboxProfiles;
625
+ if (!Array.isArray(list)) continue;
626
+ for (const entry of list) {
627
+ if (!entry || typeof entry.name !== `string`) continue;
628
+ profiles.push({
629
+ name: entry.name,
630
+ label: typeof entry.label === `string` ? entry.label : entry.name,
631
+ ...typeof entry.description === `string` && { description: entry.description },
632
+ ...typeof entry.remote === `boolean` && { remote: entry.remote }
633
+ });
634
+ }
635
+ }
636
+ return profiles;
637
+ }
494
638
  async getRunner(id) {
495
639
  const rows = await this.db.select().from(runners).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id))).limit(1);
496
640
  return rows[0] ? this.rowToRunner(rows[0]) : null;
@@ -751,6 +895,7 @@ var PostgresRegistry = class {
751
895
  tags: normalizeTags(entity.tags),
752
896
  tagsIndex: buildTagsIndex(entity.tags),
753
897
  spawnArgs: entity.spawn_args ?? {},
898
+ sandbox: entity.sandbox ?? null,
754
899
  parent: entity.parent ?? null,
755
900
  createdBy: entity.created_by ?? null,
756
901
  typeRevision: entity.type_revision ?? null,
@@ -765,6 +910,59 @@ var PostgresRegistry = class {
765
910
  pendingSourceStreams: [],
766
911
  updatedAt: new Date()
767
912
  }).onConflictDoNothing();
913
+ await tx.insert(entityLineage).values({
914
+ tenantId: this.tenantId,
915
+ ancestorUrl: entity.url,
916
+ descendantUrl: entity.url,
917
+ depth: 0
918
+ }).onConflictDoNothing();
919
+ if (entity.parent) await tx.execute(sql`
920
+ INSERT INTO ${entityLineage} (
921
+ tenant_id,
922
+ ancestor_url,
923
+ descendant_url,
924
+ depth
925
+ )
926
+ SELECT
927
+ ${this.tenantId},
928
+ ancestor_url,
929
+ ${entity.url},
930
+ depth + 1
931
+ FROM ${entityLineage}
932
+ WHERE tenant_id = ${this.tenantId}
933
+ AND descendant_url = ${entity.parent}
934
+ ON CONFLICT DO NOTHING
935
+ `);
936
+ await tx.execute(sql`
937
+ INSERT INTO ${entityEffectivePermissions} (
938
+ tenant_id,
939
+ entity_url,
940
+ source_entity_url,
941
+ source_grant_id,
942
+ permission,
943
+ subject_kind,
944
+ subject_value,
945
+ expires_at
946
+ )
947
+ SELECT
948
+ ${this.tenantId},
949
+ ${entity.url},
950
+ grants.entity_url,
951
+ grants.id,
952
+ grants.permission,
953
+ grants.subject_kind,
954
+ grants.subject_value,
955
+ grants.expires_at
956
+ FROM ${entityPermissionGrants} grants
957
+ JOIN ${entityLineage} lineage
958
+ ON lineage.tenant_id = grants.tenant_id
959
+ AND lineage.ancestor_url = grants.entity_url
960
+ AND lineage.descendant_url = ${entity.url}
961
+ WHERE grants.tenant_id = ${this.tenantId}
962
+ AND grants.propagation = 'descendants'
963
+ AND (grants.expires_at IS NULL OR grants.expires_at > now())
964
+ ON CONFLICT DO NOTHING
965
+ `);
768
966
  return parseInt(result[0].txid);
769
967
  });
770
968
  } catch (err) {
@@ -786,10 +984,8 @@ var PostgresRegistry = class {
786
984
  }
787
985
  async getEntityByStream(streamPath) {
788
986
  const mainSuffix = `/main`;
789
- const errorSuffix = `/error`;
790
987
  let entityUrl = null;
791
988
  if (streamPath.endsWith(mainSuffix)) entityUrl = streamPath.slice(0, -mainSuffix.length);
792
- else if (streamPath.endsWith(errorSuffix)) entityUrl = streamPath.slice(0, -errorSuffix.length);
793
989
  if (!entityUrl) return null;
794
990
  return this.getEntity(entityUrl);
795
991
  }
@@ -799,6 +995,23 @@ var PostgresRegistry = class {
799
995
  if (filter?.status) conditions.push(eq(entities.status, filter.status));
800
996
  if (filter?.parent) conditions.push(eq(entities.parent, filter.parent));
801
997
  if (filter?.created_by) conditions.push(eq(entities.createdBy, filter.created_by));
998
+ if (filter?.readableBy && !filter.readableBy.bypass) conditions.push(sql`(
999
+ ${entities.createdBy} = ${filter.readableBy.principalUrl}
1000
+ OR ${entities.url} IN (
1001
+ SELECT ${entityEffectivePermissions.entityUrl}
1002
+ FROM ${entityEffectivePermissions}
1003
+ WHERE ${entityEffectivePermissions.tenantId} = ${this.tenantId}
1004
+ AND ${entityEffectivePermissions.permission} IN ('read', 'manage')
1005
+ AND (${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())
1006
+ AND (
1007
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1008
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalUrl})
1009
+ OR
1010
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1011
+ AND ${entityEffectivePermissions.subjectValue} = ${filter.readableBy.principalKind})
1012
+ )
1013
+ )
1014
+ )`);
802
1015
  const whereClause = and(...conditions);
803
1016
  const countResult = await this.db.select({ count: sql`count(*)` }).from(entities).where(whereClause);
804
1017
  const total = Number(countResult[0].count);
@@ -811,6 +1024,189 @@ var PostgresRegistry = class {
811
1024
  total
812
1025
  };
813
1026
  }
1027
+ async createEntityTypePermissionGrant(input) {
1028
+ const [row] = await this.db.insert(entityTypePermissionGrants).values({
1029
+ tenantId: this.tenantId,
1030
+ entityType: input.entityType,
1031
+ permission: input.permission,
1032
+ subjectKind: input.subjectKind,
1033
+ subjectValue: input.subjectValue,
1034
+ createdBy: input.createdBy ?? null,
1035
+ expiresAt: input.expiresAt ?? null
1036
+ }).returning();
1037
+ return this.rowToEntityTypePermissionGrant(row);
1038
+ }
1039
+ async ensureEntityTypePermissionGrant(input) {
1040
+ const [existing] = await this.db.select().from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, input.entityType), eq(entityTypePermissionGrants.permission, input.permission), eq(entityTypePermissionGrants.subjectKind, input.subjectKind), eq(entityTypePermissionGrants.subjectValue, input.subjectValue), input.expiresAt ? eq(entityTypePermissionGrants.expiresAt, input.expiresAt) : sql`${entityTypePermissionGrants.expiresAt} IS NULL`)).limit(1);
1041
+ if (existing) return this.rowToEntityTypePermissionGrant(existing);
1042
+ return await this.createEntityTypePermissionGrant(input);
1043
+ }
1044
+ async listEntityTypePermissionGrants(entityType) {
1045
+ const rows = await this.db.select().from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType))).orderBy(entityTypePermissionGrants.id);
1046
+ return rows.map((row) => this.rowToEntityTypePermissionGrant(row));
1047
+ }
1048
+ async deleteEntityTypePermissionGrant(entityType, grantId) {
1049
+ const rows = await this.db.delete(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType), eq(entityTypePermissionGrants.id, grantId))).returning({ id: entityTypePermissionGrants.id });
1050
+ return rows.length > 0;
1051
+ }
1052
+ async hasEntityTypePermission(entityType, permission, subject) {
1053
+ const permissions = [permission, `manage`];
1054
+ const rows = await this.db.select({ id: entityTypePermissionGrants.id }).from(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), eq(entityTypePermissionGrants.entityType, entityType), inArray(entityTypePermissionGrants.permission, [...permissions]), sql`(${entityTypePermissionGrants.expiresAt} IS NULL OR ${entityTypePermissionGrants.expiresAt} > now())`, sql`(
1055
+ (${entityTypePermissionGrants.subjectKind} = 'principal'
1056
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalUrl})
1057
+ OR
1058
+ (${entityTypePermissionGrants.subjectKind} = 'principal_kind'
1059
+ AND ${entityTypePermissionGrants.subjectValue} = ${subject.principalKind})
1060
+ )`)).limit(1);
1061
+ return rows.length > 0;
1062
+ }
1063
+ async createEntityPermissionGrant(input) {
1064
+ return await this.db.transaction(async (tx) => {
1065
+ const [row] = await tx.insert(entityPermissionGrants).values({
1066
+ tenantId: this.tenantId,
1067
+ entityUrl: input.entityUrl,
1068
+ permission: input.permission,
1069
+ subjectKind: input.subjectKind,
1070
+ subjectValue: input.subjectValue,
1071
+ propagation: input.propagation ?? `self`,
1072
+ copyToChildren: input.copyToChildren ?? false,
1073
+ createdBy: input.createdBy ?? null,
1074
+ expiresAt: input.expiresAt ?? null
1075
+ }).returning();
1076
+ await this.materializeEntityPermissionGrant(tx, row);
1077
+ return this.rowToEntityPermissionGrant(row);
1078
+ });
1079
+ }
1080
+ async listEntityPermissionGrants(entityUrl) {
1081
+ const rows = await this.db.select().from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, entityUrl))).orderBy(entityPermissionGrants.id);
1082
+ return rows.map((row) => this.rowToEntityPermissionGrant(row));
1083
+ }
1084
+ async deleteEntityPermissionGrant(entityUrl, grantId) {
1085
+ return await this.db.transaction(async (tx) => {
1086
+ await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grantId)));
1087
+ const rows = await tx.delete(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, entityUrl), eq(entityPermissionGrants.id, grantId))).returning({ id: entityPermissionGrants.id });
1088
+ return rows.length > 0;
1089
+ });
1090
+ }
1091
+ async copyEntityPermissionGrantsForSpawn(parentEntityUrl, childEntityUrl, createdBy) {
1092
+ const parentGrants = await this.db.select().from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), eq(entityPermissionGrants.entityUrl, parentEntityUrl), eq(entityPermissionGrants.copyToChildren, true), sql`(${entityPermissionGrants.expiresAt} IS NULL OR ${entityPermissionGrants.expiresAt} > now())`));
1093
+ const copied = [];
1094
+ for (const grant of parentGrants) copied.push(await this.createEntityPermissionGrant({
1095
+ entityUrl: childEntityUrl,
1096
+ permission: grant.permission,
1097
+ subjectKind: grant.subjectKind,
1098
+ subjectValue: grant.subjectValue,
1099
+ propagation: `self`,
1100
+ copyToChildren: grant.copyToChildren,
1101
+ createdBy,
1102
+ expiresAt: grant.expiresAt ?? void 0
1103
+ }));
1104
+ return copied;
1105
+ }
1106
+ async hasEntityPermission(entityUrl, permission, subject) {
1107
+ const permissions = [permission, `manage`];
1108
+ const rows = await this.db.select({ id: entityEffectivePermissions.id }).from(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.entityUrl, entityUrl), inArray(entityEffectivePermissions.permission, [...permissions]), sql`(${entityEffectivePermissions.expiresAt} IS NULL OR ${entityEffectivePermissions.expiresAt} > now())`, sql`(
1109
+ (${entityEffectivePermissions.subjectKind} = 'principal'
1110
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalUrl})
1111
+ OR
1112
+ (${entityEffectivePermissions.subjectKind} = 'principal_kind'
1113
+ AND ${entityEffectivePermissions.subjectValue} = ${subject.principalKind})
1114
+ )`)).limit(1);
1115
+ return rows.length > 0;
1116
+ }
1117
+ async replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId) {
1118
+ await this.db.delete(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.ownerEntityUrl, ownerEntityUrl), eq(sharedStateLinks.manifestKey, manifestKey)));
1119
+ if (!sharedStateId) return;
1120
+ await this.db.insert(sharedStateLinks).values({
1121
+ tenantId: this.tenantId,
1122
+ ownerEntityUrl,
1123
+ manifestKey,
1124
+ sharedStateId
1125
+ }).onConflictDoUpdate({
1126
+ target: [
1127
+ sharedStateLinks.tenantId,
1128
+ sharedStateLinks.ownerEntityUrl,
1129
+ sharedStateLinks.manifestKey
1130
+ ],
1131
+ set: {
1132
+ sharedStateId,
1133
+ updatedAt: new Date()
1134
+ }
1135
+ });
1136
+ }
1137
+ async listSharedStateLinkedEntityUrls(sharedStateId) {
1138
+ const rows = await this.db.selectDistinct({ ownerEntityUrl: sharedStateLinks.ownerEntityUrl }).from(sharedStateLinks).where(and(eq(sharedStateLinks.tenantId, this.tenantId), eq(sharedStateLinks.sharedStateId, sharedStateId)));
1139
+ return rows.map((row) => row.ownerEntityUrl);
1140
+ }
1141
+ async pruneExpiredPermissionGrants(now = new Date(), options = {}) {
1142
+ if (this.permissionPrunePromise) return await this.permissionPrunePromise;
1143
+ const startedAt = Date.now();
1144
+ if (!options.force && startedAt - this.lastPermissionPruneStartedAt < PERMISSION_PRUNE_INTERVAL_MS) return;
1145
+ this.lastPermissionPruneStartedAt = startedAt;
1146
+ const promise = this.pruneExpiredPermissionGrantsNow(now);
1147
+ this.permissionPrunePromise = promise;
1148
+ try {
1149
+ await promise;
1150
+ } catch (error) {
1151
+ this.lastPermissionPruneStartedAt = 0;
1152
+ throw error;
1153
+ } finally {
1154
+ if (this.permissionPrunePromise === promise) this.permissionPrunePromise = null;
1155
+ }
1156
+ }
1157
+ async pruneExpiredPermissionGrantsNow(now) {
1158
+ await this.db.transaction(async (tx) => {
1159
+ const expiredEntityGrantIds = await tx.select({ id: entityPermissionGrants.id }).from(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), sql`${entityPermissionGrants.expiresAt} IS NOT NULL`, lt(entityPermissionGrants.expiresAt, now)));
1160
+ const ids = expiredEntityGrantIds.map((row) => row.id);
1161
+ if (ids.length > 0) {
1162
+ await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), inArray(entityEffectivePermissions.sourceGrantId, ids)));
1163
+ await tx.delete(entityPermissionGrants).where(and(eq(entityPermissionGrants.tenantId, this.tenantId), inArray(entityPermissionGrants.id, ids)));
1164
+ }
1165
+ await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), sql`${entityEffectivePermissions.expiresAt} IS NOT NULL`, lt(entityEffectivePermissions.expiresAt, now)));
1166
+ await tx.delete(entityTypePermissionGrants).where(and(eq(entityTypePermissionGrants.tenantId, this.tenantId), sql`${entityTypePermissionGrants.expiresAt} IS NOT NULL`, lt(entityTypePermissionGrants.expiresAt, now)));
1167
+ });
1168
+ }
1169
+ async materializeEntityPermissionGrant(tx, grant) {
1170
+ await tx.delete(entityEffectivePermissions).where(and(eq(entityEffectivePermissions.tenantId, this.tenantId), eq(entityEffectivePermissions.sourceGrantId, grant.id)));
1171
+ if (grant.propagation === `descendants`) {
1172
+ await tx.execute(sql`
1173
+ INSERT INTO ${entityEffectivePermissions} (
1174
+ tenant_id,
1175
+ entity_url,
1176
+ source_entity_url,
1177
+ source_grant_id,
1178
+ permission,
1179
+ subject_kind,
1180
+ subject_value,
1181
+ expires_at
1182
+ )
1183
+ SELECT
1184
+ ${this.tenantId},
1185
+ descendant_url,
1186
+ ${grant.entityUrl},
1187
+ ${grant.id},
1188
+ ${grant.permission},
1189
+ ${grant.subjectKind},
1190
+ ${grant.subjectValue},
1191
+ ${grant.expiresAt}
1192
+ FROM ${entityLineage}
1193
+ WHERE tenant_id = ${this.tenantId}
1194
+ AND ancestor_url = ${grant.entityUrl}
1195
+ ON CONFLICT DO NOTHING
1196
+ `);
1197
+ return;
1198
+ }
1199
+ await tx.insert(entityEffectivePermissions).values({
1200
+ tenantId: this.tenantId,
1201
+ entityUrl: grant.entityUrl,
1202
+ sourceEntityUrl: grant.entityUrl,
1203
+ sourceGrantId: grant.id,
1204
+ permission: grant.permission,
1205
+ subjectKind: grant.subjectKind,
1206
+ subjectValue: grant.subjectValue,
1207
+ expiresAt: grant.expiresAt
1208
+ }).onConflictDoNothing();
1209
+ }
814
1210
  async updateStatus(entityUrl, status$1) {
815
1211
  const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
816
1212
  await this.db.update(entities).set({
@@ -912,7 +1308,9 @@ var PostgresRegistry = class {
912
1308
  tenantId: this.tenantId,
913
1309
  sourceRef: row.sourceRef,
914
1310
  tags: normalizeTags(row.tags),
915
- streamUrl: row.streamUrl
1311
+ streamUrl: row.streamUrl,
1312
+ principalUrl: row.principalUrl,
1313
+ principalKind: row.principalKind
916
1314
  }).onConflictDoNothing();
917
1315
  const existing = await this.getEntityBridge(row.sourceRef);
918
1316
  if (!existing) throw new Error(`Failed to load entity bridge ${row.sourceRef}`);
@@ -1074,20 +1472,46 @@ var PostgresRegistry = class {
1074
1472
  updated_at: row.updatedAt
1075
1473
  };
1076
1474
  }
1475
+ rowToEntityTypePermissionGrant(row) {
1476
+ return {
1477
+ id: row.id,
1478
+ entity_type: row.entityType,
1479
+ permission: row.permission,
1480
+ subject_kind: row.subjectKind,
1481
+ subject_value: row.subjectValue,
1482
+ created_by: row.createdBy ?? void 0,
1483
+ expires_at: row.expiresAt?.toISOString(),
1484
+ created_at: row.createdAt.toISOString(),
1485
+ updated_at: row.updatedAt.toISOString()
1486
+ };
1487
+ }
1488
+ rowToEntityPermissionGrant(row) {
1489
+ return {
1490
+ id: row.id,
1491
+ entity_url: row.entityUrl,
1492
+ permission: row.permission,
1493
+ subject_kind: row.subjectKind,
1494
+ subject_value: row.subjectValue,
1495
+ propagation: row.propagation,
1496
+ copy_to_children: row.copyToChildren,
1497
+ created_by: row.createdBy ?? void 0,
1498
+ expires_at: row.expiresAt?.toISOString(),
1499
+ created_at: row.createdAt.toISOString(),
1500
+ updated_at: row.updatedAt.toISOString()
1501
+ };
1502
+ }
1077
1503
  rowToEntity(row) {
1078
1504
  return {
1079
1505
  url: row.url,
1080
1506
  type: row.type,
1081
1507
  status: assertEntityStatus(row.status),
1082
- streams: {
1083
- main: `${row.url}/main`,
1084
- error: `${row.url}/error`
1085
- },
1508
+ streams: { main: `${row.url}/main` },
1086
1509
  subscription_id: row.subscriptionId,
1087
1510
  dispatch_policy: row.dispatchPolicy ?? void 0,
1088
1511
  write_token: row.writeToken,
1089
1512
  tags: row.tags ?? {},
1090
1513
  spawn_args: row.spawnArgs,
1514
+ sandbox: row.sandbox ?? void 0,
1091
1515
  parent: row.parent ?? void 0,
1092
1516
  created_by: row.createdBy ?? void 0,
1093
1517
  type_revision: row.typeRevision ?? void 0,
@@ -1103,6 +1527,8 @@ var PostgresRegistry = class {
1103
1527
  sourceRef: row.sourceRef,
1104
1528
  tags: row.tags ?? {},
1105
1529
  streamUrl: row.streamUrl,
1530
+ principalUrl: row.principalUrl ?? void 0,
1531
+ principalKind: row.principalKind ?? void 0,
1106
1532
  shapeHandle: row.shapeHandle ?? void 0,
1107
1533
  shapeOffset: row.shapeOffset ?? void 0,
1108
1534
  lastObserverActivityAt: row.lastObserverActivityAt,
@@ -1135,6 +1561,7 @@ var PostgresRegistry = class {
1135
1561
  kind: assertRunnerKind(row.kind),
1136
1562
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1137
1563
  wake_stream: row.wakeStream,
1564
+ sandbox_profiles: row.sandboxProfiles ?? [],
1138
1565
  created_at: row.createdAt.toISOString(),
1139
1566
  updated_at: row.updatedAt.toISOString()
1140
1567
  };
@@ -1256,6 +1683,93 @@ const serverLog = {
1256
1683
  }
1257
1684
  };
1258
1685
 
1686
+ //#endregion
1687
+ //#region src/principal.ts
1688
+ const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
1689
+ const PRINCIPAL_KINDS = new Set([
1690
+ `user`,
1691
+ `agent`,
1692
+ `service`,
1693
+ `system`
1694
+ ]);
1695
+ function parsePrincipalKey(input) {
1696
+ const colon = input.indexOf(`:`);
1697
+ if (colon <= 0) throw new Error(`Invalid principal identifier`);
1698
+ const kind = input.slice(0, colon);
1699
+ const id = input.slice(colon + 1);
1700
+ if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
1701
+ if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
1702
+ const key = `${kind}:${id}`;
1703
+ return {
1704
+ kind,
1705
+ id,
1706
+ key,
1707
+ url: `/principal/${encodeURIComponent(key)}`
1708
+ };
1709
+ }
1710
+ function principalUrl(key) {
1711
+ return parsePrincipalKey(key).url;
1712
+ }
1713
+ function parsePrincipalUrl(url) {
1714
+ if (!url.startsWith(`/principal/`)) return null;
1715
+ const segment = url.slice(`/principal/`.length);
1716
+ if (!segment || segment.includes(`/`)) return null;
1717
+ try {
1718
+ return parsePrincipalKey(decodeURIComponent(segment));
1719
+ } catch {
1720
+ return null;
1721
+ }
1722
+ }
1723
+ const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
1724
+ `framework`,
1725
+ `auth-sync`,
1726
+ `dev-local`
1727
+ ]);
1728
+ function isBuiltInSystemPrincipalUrl(url) {
1729
+ if (!url?.startsWith(`/principal/`)) return false;
1730
+ try {
1731
+ const principal = parsePrincipalUrl(url);
1732
+ if (!principal) return false;
1733
+ return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
1734
+ } catch {
1735
+ return false;
1736
+ }
1737
+ }
1738
+ function principalFromCreatedBy(createdBy) {
1739
+ if (!createdBy) return void 0;
1740
+ const principal = parsePrincipalUrl(createdBy);
1741
+ if (!principal) return {
1742
+ url: createdBy,
1743
+ key: null
1744
+ };
1745
+ return {
1746
+ url: principal.url,
1747
+ key: principal.key,
1748
+ kind: principal.kind,
1749
+ id: principal.id
1750
+ };
1751
+ }
1752
+ const principalIdentityStateSchema = Type.Object({
1753
+ kind: Type.Union([
1754
+ Type.Literal(`user`),
1755
+ Type.Literal(`agent`),
1756
+ Type.Literal(`service`),
1757
+ Type.Literal(`system`)
1758
+ ]),
1759
+ id: Type.String(),
1760
+ key: Type.String(),
1761
+ url: Type.String(),
1762
+ updated_at: Type.String(),
1763
+ display_name: Type.Optional(Type.String()),
1764
+ email: Type.Optional(Type.String()),
1765
+ avatar_url: Type.Optional(Type.String()),
1766
+ auth_provider: Type.Optional(Type.String()),
1767
+ auth_subject: Type.Optional(Type.String()),
1768
+ claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1769
+ created_at: Type.Optional(Type.String())
1770
+ }, { additionalProperties: false });
1771
+ const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
1772
+
1259
1773
  //#endregion
1260
1774
  //#region src/entity-projector.ts
1261
1775
  const ENTITY_SHAPE_COLUMNS = [
@@ -1264,7 +1778,9 @@ const ENTITY_SHAPE_COLUMNS = [
1264
1778
  `type`,
1265
1779
  `status`,
1266
1780
  `tags`,
1781
+ `created_by`,
1267
1782
  `spawn_args`,
1783
+ `sandbox`,
1268
1784
  `parent`,
1269
1785
  `type_revision`,
1270
1786
  `inbox_schemas`,
@@ -1282,6 +1798,12 @@ function sourceRefFromStreamPath(streamPath) {
1282
1798
  const match = streamPath.match(/^\/_entities\/([^/]+)$/);
1283
1799
  return match?.[1] ?? null;
1284
1800
  }
1801
+ function principalScopedSourceRef(tagSourceRef, principalUrl$1, principalKind) {
1802
+ return `${tagSourceRef}-${hashString(JSON.stringify({
1803
+ principalKind,
1804
+ principalUrl: principalUrl$1
1805
+ }))}`;
1806
+ }
1285
1807
  function sameMember(left, right) {
1286
1808
  return JSON.stringify(left) === JSON.stringify(right);
1287
1809
  }
@@ -1298,6 +1820,7 @@ function toMemberRow(entity) {
1298
1820
  status: entity.status,
1299
1821
  tags: entity.tags,
1300
1822
  spawn_args: entity.spawn_args ?? {},
1823
+ sandbox: entity.sandbox ?? null,
1301
1824
  parent: entity.parent ?? null,
1302
1825
  type_revision: entity.type_revision ?? null,
1303
1826
  inbox_schemas: entity.inbox_schemas ?? null,
@@ -1311,15 +1834,22 @@ var ProjectedEntityBridge = class {
1311
1834
  sourceRef;
1312
1835
  tags;
1313
1836
  streamUrl;
1837
+ principalUrl;
1838
+ principalKind;
1839
+ permissionBypass;
1314
1840
  currentMembers = new Map();
1315
1841
  producer = null;
1316
1842
  stopped = false;
1317
- constructor(row, streamClient) {
1843
+ constructor(row, registry, streamClient) {
1844
+ this.registry = registry;
1318
1845
  this.streamClient = streamClient;
1319
1846
  this.tenantId = row.tenantId;
1320
1847
  this.sourceRef = row.sourceRef;
1321
1848
  this.tags = normalizeTags(row.tags);
1322
1849
  this.streamUrl = row.streamUrl;
1850
+ this.principalUrl = row.principalUrl;
1851
+ this.principalKind = row.principalKind;
1852
+ this.permissionBypass = isBuiltInSystemPrincipalUrl(row.principalUrl);
1323
1853
  }
1324
1854
  async start(initialEntities) {
1325
1855
  await this.ensureStream();
@@ -1333,7 +1863,7 @@ var ProjectedEntityBridge = class {
1333
1863
  }
1334
1864
  });
1335
1865
  await this.loadCurrentMembers();
1336
- this.reconcile(initialEntities);
1866
+ await this.reconcile(initialEntities);
1337
1867
  }
1338
1868
  async stop() {
1339
1869
  this.stopped = true;
@@ -1345,12 +1875,13 @@ var ProjectedEntityBridge = class {
1345
1875
  this.producer = null;
1346
1876
  }
1347
1877
  }
1348
- reconcile(entities$1) {
1878
+ async reconcile(entities$1) {
1349
1879
  if (this.stopped) return;
1350
1880
  const staleMembers = new Map(this.currentMembers);
1351
1881
  for (const entity of entities$1) {
1352
1882
  if (entity.tenant_id !== this.tenantId) continue;
1353
1883
  if (!entityMatchesTags(entity, this.tags)) continue;
1884
+ if (!await this.canReadEntity(entity)) continue;
1354
1885
  staleMembers.delete(entity.url);
1355
1886
  this.upsertEntity(entity);
1356
1887
  }
@@ -1359,10 +1890,10 @@ var ProjectedEntityBridge = class {
1359
1890
  this.currentMembers.delete(url);
1360
1891
  }
1361
1892
  }
1362
- applyEntity(entity) {
1893
+ async applyEntity(entity) {
1363
1894
  if (this.stopped) return;
1364
1895
  if (entity.tenant_id !== this.tenantId) return;
1365
- if (!entityMatchesTags(entity, this.tags)) {
1896
+ if (!entityMatchesTags(entity, this.tags) || !await this.canReadEntity(entity)) {
1366
1897
  const existing = this.currentMembers.get(entity.url);
1367
1898
  if (!existing) return;
1368
1899
  this.append(`delete`, existing);
@@ -1391,6 +1922,15 @@ var ProjectedEntityBridge = class {
1391
1922
  this.currentMembers.set(entity.url, next);
1392
1923
  }
1393
1924
  }
1925
+ async canReadEntity(entity) {
1926
+ if (this.permissionBypass) return true;
1927
+ if (!this.principalUrl || !this.principalKind) return false;
1928
+ if (entity.created_by === this.principalUrl) return true;
1929
+ return await this.registry.hasEntityPermission(entity.url, `read`, {
1930
+ principalUrl: this.principalUrl,
1931
+ principalKind: this.principalKind
1932
+ });
1933
+ }
1394
1934
  async ensureStream() {
1395
1935
  if (!await this.streamClient.exists(this.streamUrl)) await this.streamClient.create(this.streamUrl, { contentType: `application/json` });
1396
1936
  }
@@ -1495,17 +2035,19 @@ var EntityProjector = class {
1495
2035
  this.activeReaders.clear();
1496
2036
  await Promise.all(projections.map((projection) => projection.stop()));
1497
2037
  }
1498
- async register(tenantId, registry, tagsInput) {
2038
+ async register(tenantId, registry, tagsInput, principalUrl$1, principalKind) {
1499
2039
  if (!this.electricUrl) throw new Error(`[entity-projector] Electric URL is required for entities()`);
1500
2040
  await this.start();
1501
2041
  this.registries.set(tenantId, registry);
1502
2042
  const tags = normalizeTags(assertTags(tagsInput));
1503
- const sourceRef = sourceRefForTags(tags);
2043
+ const sourceRef = principalScopedSourceRef(sourceRefForTags(tags), principalUrl$1, principalKind);
1504
2044
  const streamUrl = getEntitiesStreamPath(sourceRef);
1505
2045
  const row = await registry.upsertEntityBridge({
1506
2046
  sourceRef,
1507
2047
  tags,
1508
- streamUrl
2048
+ streamUrl,
2049
+ principalUrl: principalUrl$1,
2050
+ principalKind
1509
2051
  });
1510
2052
  await registry.touchEntityBridge(sourceRef);
1511
2053
  await this.ensureProjection(row);
@@ -1534,7 +2076,11 @@ var EntityProjector = class {
1534
2076
  await this.touchSourceRef(tenantId, registry, sourceRef, `read-close`);
1535
2077
  };
1536
2078
  }
1537
- async onEntityChanged(_tenantId, _entityUrl) {}
2079
+ async onEntityChanged(tenantId, entityUrl) {
2080
+ const entity = this.entities.get(entityKey(tenantId, entityUrl));
2081
+ if (!entity) return;
2082
+ for (const projection of this.projectionsForTenant(tenantId)) await projection.applyEntity(entity);
2083
+ }
1538
2084
  async loadTenantBridges(tenantId, registry = this.registryForTenant(tenantId)) {
1539
2085
  if (!this.started || !this.electricUrl) return;
1540
2086
  await this.loadPersistedBridgesForTenant(tenantId, registry);
@@ -1595,16 +2141,16 @@ var EntityProjector = class {
1595
2141
  }
1596
2142
  if (message.headers.control === `up-to-date`) {
1597
2143
  this.upToDate = true;
1598
- this.reconcileAll();
2144
+ await this.reconcileAll();
1599
2145
  this.readyResolve?.();
1600
2146
  }
1601
2147
  continue;
1602
2148
  }
1603
2149
  if (!isChangeMessage(message)) continue;
1604
- this.applyChangeMessage(message);
2150
+ await this.applyChangeMessage(message);
1605
2151
  }
1606
2152
  }
1607
- applyChangeMessage(message) {
2153
+ async applyChangeMessage(message) {
1608
2154
  const entity = message.value;
1609
2155
  const key = entityKey(entity.tenant_id, entity.url);
1610
2156
  if (message.headers.operation === `delete`) {
@@ -1613,7 +2159,7 @@ var EntityProjector = class {
1613
2159
  return;
1614
2160
  }
1615
2161
  this.entities.set(key, entity);
1616
- if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) projection.applyEntity(entity);
2162
+ if (this.upToDate) for (const projection of this.projectionsForTenant(entity.tenant_id)) await projection.applyEntity(entity);
1617
2163
  }
1618
2164
  async loadPersistedBridges() {
1619
2165
  const registry = new PostgresRegistry(this.db);
@@ -1676,7 +2222,7 @@ var EntityProjector = class {
1676
2222
  }
1677
2223
  throw error;
1678
2224
  }
1679
- const projection = new ProjectedEntityBridge(row, streamClient);
2225
+ const projection = new ProjectedEntityBridge(row, this.registryForTenant(row.tenantId), streamClient);
1680
2226
  await projection.start(this.entitiesForTenant(row.tenantId));
1681
2227
  this.projections.set(key, projection);
1682
2228
  })().finally(() => {
@@ -1691,8 +2237,8 @@ var EntityProjector = class {
1691
2237
  projectionsForTenant(tenantId) {
1692
2238
  return [...this.projections.values()].filter((projection) => projection.tenantId === tenantId);
1693
2239
  }
1694
- reconcileAll() {
1695
- for (const projection of this.projections.values()) projection.reconcile(this.entitiesForTenant(projection.tenantId));
2240
+ async reconcileAll() {
2241
+ for (const projection of this.projections.values()) await projection.reconcile(this.entitiesForTenant(projection.tenantId));
1696
2242
  }
1697
2243
  async touchSourceRef(tenantId, registry, sourceRef, reason) {
1698
2244
  try {
@@ -1734,8 +2280,8 @@ var EntityProjectorTenantFacade = class {
1734
2280
  await this.projector.start();
1735
2281
  }
1736
2282
  async stop() {}
1737
- async register(tagsInput) {
1738
- return await this.projector.register(this.tenantId, this.registry, tagsInput);
2283
+ async register(tagsInput, principalUrl$1, principalKind) {
2284
+ return await this.projector.register(this.tenantId, this.registry, tagsInput, principalUrl$1, principalKind);
1739
2285
  }
1740
2286
  async onEntityChanged(entityUrl) {
1741
2287
  await this.projector.onEntityChanged(this.tenantId, entityUrl);
@@ -1978,7 +2524,7 @@ var StreamClient = class {
1978
2524
  });
1979
2525
  });
1980
2526
  }
1981
- async fork(path$1, sourcePath) {
2527
+ async fork(path$1, sourcePath, opts) {
1982
2528
  return await withSpan(`stream.fork`, async (span) => {
1983
2529
  span.setAttributes({
1984
2530
  [ATTR.STREAM_PATH]: path$1,
@@ -1988,6 +2534,11 @@ var StreamClient = class {
1988
2534
  "content-type": `application/json`,
1989
2535
  "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
1990
2536
  };
2537
+ if (opts?.forkPointer) {
2538
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`;
2539
+ headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET;
2540
+ if (opts.forkPointer.subOffset > 0) headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset);
2541
+ }
1991
2542
  injectTraceHeaders(headers);
1992
2543
  const response = await fetch(this.streamUrl(path$1), {
1993
2544
  method: `PUT`,
@@ -2516,91 +3067,101 @@ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionI
2516
3067
  }
2517
3068
 
2518
3069
  //#endregion
2519
- //#region src/principal.ts
2520
- const ELECTRIC_PRINCIPAL_HEADER = `electric-principal`;
2521
- const PRINCIPAL_KINDS = new Set([
2522
- `user`,
2523
- `agent`,
2524
- `service`,
2525
- `system`
2526
- ]);
2527
- function parsePrincipalKey(input) {
2528
- const colon = input.indexOf(`:`);
2529
- if (colon <= 0) throw new Error(`Invalid principal identifier`);
2530
- const kind = input.slice(0, colon);
2531
- const id = input.slice(colon + 1);
2532
- if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
2533
- if (!id || id.includes(`/`)) throw new Error(`Invalid principal id`);
2534
- const key = `${kind}:${id}`;
3070
+ //#region src/routing/sandbox.ts
3071
+ /**
3072
+ * Resolve and validate a spawn's sandbox CHOICE into the {@link
3073
+ * EntitySandboxSelection} persisted on the entity. Sibling of
3074
+ * `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
3075
+ * EntityManager so the spawn path reads as composed resolution steps.
3076
+ *
3077
+ * Profiles are a per-runner concern: each runner advertises what it supports.
3078
+ * When the spawn pins a runner via dispatch_policy, the chosen profile must be
3079
+ * in that runner's advertised set; otherwise we'd persist an unserviceable
3080
+ * choice that fails late at first wake. For unpinned dispatch (webhook /
3081
+ * parent-inherited) we can't pick a target ahead of time, so we fall back to a
3082
+ * tenant-wide "some runner offers this" check — better than nothing.
3083
+ */
3084
+ async function resolveSandboxForSpawn(registry, dispatchPolicy, requested, parentEntity) {
3085
+ if (!requested) return void 0;
3086
+ const choice = applyInheritedSandbox(requested, parentEntity);
3087
+ if (!choice) return void 0;
3088
+ const chosenName = choice.profile;
3089
+ if (!chosenName) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`, 400);
3090
+ const chosenIsRemote = await resolveChosenProfileRemote(registry, chosenName, dispatchPolicy);
3091
+ assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy);
3092
+ const selection = { profile: chosenName };
3093
+ if (choice.key !== void 0) selection.key = choice.key;
3094
+ else if (choice.scope !== void 0) selection.scope = choice.scope;
3095
+ if (choice.persistent !== void 0) selection.persistent = choice.persistent;
3096
+ if (choice.owner === false) selection.owner = false;
3097
+ return selection;
3098
+ }
3099
+ /**
3100
+ * Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
3101
+ * parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
3102
+ * parent has no shareable (keyed) sandbox the child simply gets none (returns
3103
+ * `undefined`), so `spawn_worker` can always request inheritance without
3104
+ * breaking unkeyed parents. (A running parent wake resolves inherit to its live
3105
+ * explicit key in the runtime instead — this server-side path covers direct API
3106
+ * callers, where only the parent's *stored* explicit key is available.)
3107
+ *
3108
+ * For a non-inherit choice the request passes through unchanged.
3109
+ *
3110
+ * NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
3111
+ * any sibling field on the request (e.g. a caller-supplied `persistent: false`)
3112
+ * is intentionally ignored, because a child attaches to the parent's existing
3113
+ * sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
3114
+ * permits the `{ inherit: true, persistent: ... }` combination, so the
3115
+ * precedence is resolved here rather than rejected at the schema level.
3116
+ */
3117
+ function applyInheritedSandbox(requested, parentEntity) {
3118
+ if (!requested.inherit) return requested;
3119
+ const parentKey = parentEntity?.sandbox?.key;
3120
+ if (!parentKey) return void 0;
2535
3121
  return {
2536
- kind,
2537
- id,
2538
- key,
2539
- url: `/principal/${encodeURIComponent(key)}`
3122
+ profile: parentEntity.sandbox.profile,
3123
+ key: parentKey,
3124
+ persistent: parentEntity.sandbox.persistent,
3125
+ owner: false
2540
3126
  };
2541
3127
  }
2542
- function principalUrl(key) {
2543
- return parsePrincipalKey(key).url;
2544
- }
2545
- function parsePrincipalUrl(url) {
2546
- if (!url.startsWith(`/principal/`)) return null;
2547
- const segment = url.slice(`/principal/`.length);
2548
- if (!segment || segment.includes(`/`)) return null;
2549
- try {
2550
- return parsePrincipalKey(decodeURIComponent(segment));
2551
- } catch {
2552
- return null;
2553
- }
2554
- }
2555
- const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
2556
- `framework`,
2557
- `auth-sync`,
2558
- `dev-local`
2559
- ]);
2560
- function isBuiltInSystemPrincipalUrl(url) {
2561
- if (!url?.startsWith(`/principal/`)) return false;
2562
- try {
2563
- const principal = parsePrincipalUrl(url);
2564
- if (!principal) return false;
2565
- return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
2566
- } catch {
2567
- return false;
2568
- }
3128
+ /**
3129
+ * Validate the chosen profile is advertised by the relevant runner(s) and
3130
+ * determine whether it is a remote (off-host) sandbox, reachable from any
3131
+ * runner. Defaults to host-local (co-location required) unless every relevant
3132
+ * advertisement marks it remote. Throws if the profile is unserviceable.
3133
+ */
3134
+ async function resolveChosenProfileRemote(registry, chosenName, dispatchPolicy) {
3135
+ const runnerIds = [];
3136
+ for (const target of dispatchPolicy?.targets ?? []) if (target.type === `runner`) runnerIds.push(target.runnerId);
3137
+ if (runnerIds.length > 0) {
3138
+ let allRemote = true;
3139
+ for (const runnerId of runnerIds) {
3140
+ const runner = await registry.getRunner(runnerId);
3141
+ const advertised = runner?.sandbox_profiles ?? [];
3142
+ const match = advertised.find((p) => p.name === chosenName);
3143
+ if (!match) throw new ElectricAgentsError(ErrCodeInvalidRequest, `sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`, 400);
3144
+ if (match.remote !== true) allRemote = false;
3145
+ }
3146
+ return allRemote;
3147
+ }
3148
+ const available = await registry.listSandboxProfiles();
3149
+ const matches = available.filter((p) => p.name === chosenName);
3150
+ 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);
3151
+ return matches.every((p) => p.remote === true);
2569
3152
  }
2570
- function principalFromCreatedBy(createdBy) {
2571
- if (!createdBy) return void 0;
2572
- const principal = parsePrincipalUrl(createdBy);
2573
- if (!principal) return {
2574
- url: createdBy,
2575
- key: null
2576
- };
2577
- return {
2578
- url: principal.url,
2579
- key: principal.key,
2580
- kind: principal.kind,
2581
- id: principal.id
2582
- };
3153
+ /**
3154
+ * Co-location: a shared *local* sandbox lives on one host, so every
3155
+ * collaborator must be pinned to the same single runner. Subagents inherit the
3156
+ * parent's dispatch policy, so this holds once the root is pinned. A shared
3157
+ * *remote* sandbox is reachable from any runner, so the guard does not apply.
3158
+ */
3159
+ function assertSharedSandboxColocated(key, chosenIsRemote, dispatchPolicy) {
3160
+ if (key === void 0 || chosenIsRemote) return;
3161
+ const targets = dispatchPolicy?.targets ?? [];
3162
+ const pinnedToSingleRunner = targets.length === 1 && targets[0]?.type === `runner`;
3163
+ if (!pinnedToSingleRunner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`, 400);
2583
3164
  }
2584
- const principalIdentityStateSchema = Type.Object({
2585
- kind: Type.Union([
2586
- Type.Literal(`user`),
2587
- Type.Literal(`agent`),
2588
- Type.Literal(`service`),
2589
- Type.Literal(`system`)
2590
- ]),
2591
- id: Type.String(),
2592
- key: Type.String(),
2593
- url: Type.String(),
2594
- updated_at: Type.String(),
2595
- display_name: Type.Optional(Type.String()),
2596
- email: Type.Optional(Type.String()),
2597
- avatar_url: Type.Optional(Type.String()),
2598
- auth_provider: Type.Optional(Type.String()),
2599
- auth_subject: Type.Optional(Type.String()),
2600
- claims: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
2601
- created_at: Type.Optional(Type.String())
2602
- }, { additionalProperties: false });
2603
- const principalUpdateIdentityMessageSchema = Type.Object({ identity: principalIdentityStateSchema }, { additionalProperties: false });
2604
3165
 
2605
3166
  //#endregion
2606
3167
  //#region src/manifest-side-effects.ts
@@ -2837,7 +3398,10 @@ var EntityManager = class {
2837
3398
  }
2838
3399
  async ensurePrincipal(principal) {
2839
3400
  const existing = await this.registry.getEntity(principal.url);
2840
- if (existing) return existing;
3401
+ if (existing) {
3402
+ await this.ensureUserPrincipal(principal);
3403
+ return existing;
3404
+ }
2841
3405
  await this.ensurePrincipalEntityType();
2842
3406
  try {
2843
3407
  const entity = await this.spawn(`principal`, {
@@ -2866,15 +3430,22 @@ var EntityManager = class {
2866
3430
  updated_at: now
2867
3431
  }
2868
3432
  }));
3433
+ await this.ensureUserPrincipal(principal);
2869
3434
  return entity;
2870
3435
  } catch (error) {
2871
3436
  if (error instanceof ElectricAgentsError && error.code === ErrCodeDuplicateURL) {
2872
3437
  const raced = await this.registry.getEntity(principal.url);
2873
- if (raced) return raced;
3438
+ if (raced) {
3439
+ await this.ensureUserPrincipal(principal);
3440
+ return raced;
3441
+ }
2874
3442
  }
2875
3443
  throw error;
2876
3444
  }
2877
3445
  }
3446
+ async ensureUserPrincipal(principal) {
3447
+ if (principal.kind === `user`) await this.registry.ensureUserForPrincipal(principal);
3448
+ }
2878
3449
  /**
2879
3450
  * Spawn a new entity of the given type with durable streams.
2880
3451
  */
@@ -2904,7 +3475,6 @@ var EntityManager = class {
2904
3475
  const writeToken = randomUUID();
2905
3476
  const entityURL = typeName === `principal` ? principalUrl(instanceId) : `/${typeName}/${instanceId}`;
2906
3477
  const mainPath = `${entityURL}/main`;
2907
- const errorPath = `${entityURL}/error`;
2908
3478
  const subscriptionId = `${typeName}-handler`;
2909
3479
  const spawnT0 = performance.now();
2910
3480
  const existingByURL = await this.registry.getEntity(entityURL);
@@ -2915,20 +3485,19 @@ var EntityManager = class {
2915
3485
  if (!parentEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Parent entity "${req.parent}" not found`, 404);
2916
3486
  }
2917
3487
  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;
3488
+ const sandbox = await resolveSandboxForSpawn(this.registry, dispatchPolicy, req.sandbox, parentEntity);
2918
3489
  const now = Date.now();
2919
3490
  const entityData = {
2920
3491
  type: typeName,
2921
3492
  status: `idle`,
2922
3493
  url: entityURL,
2923
- streams: {
2924
- main: mainPath,
2925
- error: errorPath
2926
- },
3494
+ streams: { main: mainPath },
2927
3495
  subscription_id: subscriptionId,
2928
3496
  dispatch_policy: dispatchPolicy,
2929
3497
  write_token: writeToken,
2930
3498
  tags: initialTags,
2931
3499
  spawn_args: req.args,
3500
+ sandbox,
2932
3501
  type_revision: entityType.revision,
2933
3502
  inbox_schemas: entityType.inbox_schemas,
2934
3503
  state_schemas: entityType.state_schemas,
@@ -2975,55 +3544,43 @@ var EntityManager = class {
2975
3544
  const queueEnterT0 = performance.now();
2976
3545
  const queueWaiting = this.spawnPersistQueue.length();
2977
3546
  const queueRunning = this.spawnPersistQueue.running();
2978
- const [mainStreamResult, errorStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
3547
+ const [mainStreamResult, entityResult] = await this.spawnPersistQueue.push(async () => {
2979
3548
  let entityTxid;
2980
3549
  try {
2981
3550
  entityTxid = await withSpan(`db.createEntity`, () => this.registry.createEntity(entityData));
2982
3551
  } catch (err) {
2983
- return [
2984
- {
2985
- status: `fulfilled`,
2986
- value: void 0
2987
- },
2988
- {
2989
- status: `fulfilled`,
2990
- value: void 0
2991
- },
2992
- {
2993
- status: `rejected`,
2994
- reason: err
2995
- }
2996
- ];
3552
+ return [{
3553
+ status: `fulfilled`,
3554
+ value: void 0
3555
+ }, {
3556
+ status: `rejected`,
3557
+ reason: err
3558
+ }];
2997
3559
  }
2998
- const [mainStreamResult$1, errorStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
3560
+ const [mainStreamResult$1] = await Promise.allSettled([this.streamClient.create(mainPath, {
2999
3561
  contentType,
3000
3562
  body: initialBody
3001
- }), this.streamClient.create(errorPath, { contentType })]);
3002
- return [
3003
- mainStreamResult$1,
3004
- errorStreamResult$1,
3005
- {
3006
- status: `fulfilled`,
3007
- value: entityTxid
3008
- }
3009
- ];
3563
+ })]);
3564
+ return [mainStreamResult$1, {
3565
+ status: `fulfilled`,
3566
+ value: entityTxid
3567
+ }];
3010
3568
  });
3011
3569
  const parallelMs = +(performance.now() - queueEnterT0).toFixed(2);
3012
- if (mainStreamResult.status === `rejected` || errorStreamResult.status === `rejected` || entityResult.status === `rejected`) {
3570
+ if (mainStreamResult.status === `rejected` || entityResult.status === `rejected`) {
3013
3571
  const entityReason = entityResult.status === `rejected` ? entityResult.reason : null;
3014
- const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : errorStreamResult.status === `rejected` ? errorStreamResult.reason : null;
3572
+ const streamReason = mainStreamResult.status === `rejected` ? mainStreamResult.reason : null;
3015
3573
  const isDuplicate = entityReason instanceof EntityAlreadyExistsError;
3016
3574
  const isStreamConflict = !!streamReason && typeof streamReason === `object` && (`status` in streamReason && streamReason.status === 409 || `code` in streamReason && streamReason.code === `CONFLICT_SEQ`);
3017
3575
  const rollbacks = [];
3018
3576
  if (!isDuplicate && !isStreamConflict) {
3019
3577
  if (mainStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(mainPath));
3020
- if (errorStreamResult.status === `fulfilled`) rollbacks.push(this.streamClient.delete(errorPath));
3021
3578
  if (entityResult.status === `fulfilled`) rollbacks.push(this.registry.deleteEntity(entityURL));
3022
3579
  if (req.wake) rollbacks.push(this.wakeRegistry.unregisterBySubscriberAndSource(req.wake.subscriberUrl, entityURL, this.tenantId));
3023
3580
  await Promise.allSettled(rollbacks);
3024
3581
  }
3025
3582
  if (isDuplicate || isStreamConflict) throw new ElectricAgentsError(ErrCodeDuplicateURL, `Entity already exists at URL "${entityURL}"`, 409);
3026
- const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : errorStreamResult.status === `rejected` ? errorStreamResult.reason : entityResult.reason;
3583
+ const failure = mainStreamResult.status === `rejected` ? mainStreamResult.reason : entityResult.reason;
3027
3584
  if (failure instanceof Error) throw failure;
3028
3585
  throw new ElectricAgentsError(`SPAWN_FAILED`, `Spawn failed: ${String(failure)}`, 500);
3029
3586
  }
@@ -3058,30 +3615,67 @@ var EntityManager = class {
3058
3615
  const writeEntityLocks = new Set();
3059
3616
  const writeStreamLocks = new Set();
3060
3617
  try {
3061
- const sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3618
+ let sourceTree;
3619
+ if (opts.forkPointer) {
3620
+ const rootEntity = await this.registry.getEntity(rootUrl);
3621
+ if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3622
+ if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
3623
+ sourceTree = await this.listEntitySubtree(rootEntity);
3624
+ } else sourceTree = await this.waitForIdleSubtree(rootUrl, opts, workLocks);
3062
3625
  const sourceRoot = sourceTree[0];
3063
3626
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3064
- const snapshot = await this.readForkStateSnapshot(sourceTree);
3627
+ let preFilteredRoot;
3628
+ if (opts.forkPointer) {
3629
+ const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3630
+ const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3631
+ const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3632
+ const filteredEvents = flat.slice(0, target);
3633
+ const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3634
+ const sharedStateIds = new Set();
3635
+ for (const manifest of rootManifests.values()) this.collectSharedStateIds(manifest, sharedStateIds);
3636
+ preFilteredRoot = {
3637
+ manifests: rootManifests,
3638
+ childStatuses: this.reduceStateRows(filteredEvents, `child_status`),
3639
+ replayWatermarks: this.reduceStateRows(filteredEvents, `replay_watermark`),
3640
+ sharedStateIds
3641
+ };
3642
+ }
3643
+ const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3644
+ if (opts.forkPointer) {
3645
+ const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3646
+ if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3647
+ }
3648
+ const snapshot = await this.readForkStateSnapshot(
3649
+ // Skip the root when we've already pre-filtered it — avoid both a
3650
+ // wasted HEAD read of main and a re-population that would clobber
3651
+ // the filtered entries.
3652
+ preFilteredRoot ? effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url) : effectiveSubtree
3653
+ );
3654
+ if (preFilteredRoot) {
3655
+ snapshot.manifestsByEntity.set(sourceRoot.url, preFilteredRoot.manifests);
3656
+ snapshot.childStatusesByEntity.set(sourceRoot.url, preFilteredRoot.childStatuses);
3657
+ snapshot.replayWatermarksByEntity.set(sourceRoot.url, preFilteredRoot.replayWatermarks);
3658
+ for (const id of preFilteredRoot.sharedStateIds) snapshot.sharedStateIds.add(id);
3659
+ }
3065
3660
  const suffix = randomUUID().slice(0, 8);
3066
- const entityUrlMap = await this.buildForkEntityUrlMap(sourceTree, {
3661
+ const entityUrlMap = await this.buildForkEntityUrlMap(effectiveSubtree, {
3067
3662
  suffix,
3068
3663
  rootUrl,
3069
3664
  rootInstanceId: opts.rootInstanceId
3070
3665
  });
3071
3666
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3072
3667
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3073
- const entityPlans = this.buildForkEntityPlans(sourceTree, entityUrlMap, stringMap);
3074
- this.addForkLocks(this.forkWriteLockedEntities, sourceTree.map((entity) => entity.url), writeEntityLocks);
3668
+ const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
3669
+ this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3075
3670
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3076
3671
  const createdStreams = [];
3077
3672
  const createdEntities = [];
3078
3673
  const activeManifestsByEntity = new Map();
3079
3674
  try {
3080
3675
  for (const plan of entityPlans) {
3081
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main);
3676
+ const isRoot = plan.source.url === rootUrl;
3677
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3082
3678
  createdStreams.push(plan.fork.streams.main);
3083
- await this.streamClient.fork(plan.fork.streams.error, plan.source.streams.error);
3084
- createdStreams.push(plan.fork.streams.error);
3085
3679
  }
3086
3680
  for (const [sourceId, forkId] of sharedStateIdMap) {
3087
3681
  const sourcePath = getSharedStateStreamPath(sourceId);
@@ -3171,6 +3765,38 @@ var EntityManager = class {
3171
3765
  }
3172
3766
  held.clear();
3173
3767
  }
3768
+ /**
3769
+ * Variant of {@link waitForIdleSubtree} that takes an explicit entity
3770
+ * list instead of walking the registry from `rootUrl`. Used by the
3771
+ * pointer-fork path to wait+lock only the kept descendants, since
3772
+ * the root is being forked from history and doesn't need to be idle.
3773
+ */
3774
+ async waitForGivenEntitiesIdle(entities$1, opts, workLocks) {
3775
+ if (entities$1.length === 0) return;
3776
+ const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3777
+ const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
3778
+ const refresh = async () => {
3779
+ const refreshed = await Promise.all(entities$1.map((entity) => this.registry.getEntity(entity.url)));
3780
+ return refreshed.filter((entity) => !!entity);
3781
+ };
3782
+ const deadline = Date.now() + timeoutMs;
3783
+ while (true) {
3784
+ const present = await refresh();
3785
+ const stopped = present.find((entity) => isTerminalEntityStatus(entity.status));
3786
+ if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
3787
+ let active = present.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3788
+ if (active.length === 0) {
3789
+ this.addForkLocks(this.forkWorkLockedEntities, present.map((entity) => entity.url), workLocks);
3790
+ const reChecked = await refresh();
3791
+ const reActive = reChecked.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
3792
+ if (reActive.length === 0) return;
3793
+ this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
3794
+ active = reActive;
3795
+ }
3796
+ if (Date.now() >= deadline) throw new ElectricAgentsError(ErrCodeForkWaitTimeout, `Timed out waiting for descendants to become idle`, 409, { active: active.map((entity) => entity.url) });
3797
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3798
+ }
3799
+ }
3174
3800
  async waitForIdleSubtree(rootUrl, opts, workLocks) {
3175
3801
  const timeoutMs = opts.waitTimeoutMs ?? DEFAULT_FORK_WAIT_TIMEOUT_MS;
3176
3802
  const pollMs = opts.waitPollMs ?? DEFAULT_FORK_WAIT_POLL_MS;
@@ -3200,6 +3826,73 @@ var EntityManager = class {
3200
3826
  await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
3201
3827
  }
3202
3828
  }
3829
+ /**
3830
+ * Translate `forkPointer` into a 1-indexed CUMULATIVE position in the
3831
+ * source's flattened history. Throws a 400 if the pointer doesn't
3832
+ * address a real event.
3833
+ *
3834
+ * Semantics (mirroring the durable-streams server interpretation):
3835
+ * `{ offset: X, subOffset: N }` means "from anchor X, take N flattened
3836
+ * messages forward." Concretely, the target event is the N-th event
3837
+ * after the last event whose `headers.offset` is ≤ X. (When `X` is
3838
+ * `null`, the anchor is the stream start and the target is the N-th
3839
+ * event from the very beginning.) The returned position is the count
3840
+ * of events to KEEP — events 1..position survive the filter.
3841
+ *
3842
+ * A pointer is valid when:
3843
+ * - `pointer.offset` is `null` (stream start) OR matches some
3844
+ * event's `headers.offset` value, AND
3845
+ * - `pointer.subOffset` is in `[1, total events past the anchor]`.
3846
+ */
3847
+ resolveForkPointerTarget(events, pointer, streamPath) {
3848
+ let positionAtAnchor = 0;
3849
+ let anchorSeen = pointer.offset === null;
3850
+ for (const event of events) {
3851
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3852
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : void 0;
3853
+ if (eventOffset === void 0) continue;
3854
+ if (pointer.offset === null) continue;
3855
+ if (eventOffset === pointer.offset) anchorSeen = true;
3856
+ if (eventOffset <= pointer.offset) positionAtAnchor++;
3857
+ }
3858
+ 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);
3859
+ const eventsPastAnchor = events.length - positionAtAnchor;
3860
+ 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);
3861
+ return positionAtAnchor + pointer.subOffset;
3862
+ }
3863
+ /**
3864
+ * Compute the subset of `sourceTree` that survives the manifest filter
3865
+ * applied at the root. After filtering the root's manifest at the fork
3866
+ * pointer, only children whose manifest entries landed at or before the
3867
+ * pointer remain; those kept children carry their CURRENT (HEAD) subtree
3868
+ * along with them. Children dropped from the root's manifest, and any
3869
+ * of their descendants, are excluded.
3870
+ */
3871
+ computeEffectiveSubtree(sourceTree, rootUrl, filteredRootManifests) {
3872
+ const keptChildUrls = new Set();
3873
+ for (const value of filteredRootManifests.values()) if (value.kind === `child` && typeof value.entity_url === `string`) keptChildUrls.add(value.entity_url);
3874
+ const childrenByParent = new Map();
3875
+ for (const entity of sourceTree) {
3876
+ if (!entity.parent) continue;
3877
+ const list = childrenByParent.get(entity.parent) ?? [];
3878
+ list.push(entity);
3879
+ childrenByParent.set(entity.parent, list);
3880
+ }
3881
+ const rootEntity = sourceTree.find((e) => e.url === rootUrl);
3882
+ if (!rootEntity) return [];
3883
+ const result = [rootEntity];
3884
+ const queue = [];
3885
+ for (const child of childrenByParent.get(rootUrl) ?? []) if (keptChildUrls.has(child.url)) queue.push(child);
3886
+ const seen = new Set([rootUrl]);
3887
+ while (queue.length > 0) {
3888
+ const entity = queue.shift();
3889
+ if (seen.has(entity.url)) continue;
3890
+ seen.add(entity.url);
3891
+ result.push(entity);
3892
+ for (const grandchild of childrenByParent.get(entity.url) ?? []) if (!seen.has(grandchild.url)) queue.push(grandchild);
3893
+ }
3894
+ return result;
3895
+ }
3203
3896
  async listEntitySubtree(root) {
3204
3897
  const result = [];
3205
3898
  const queue = [root];
@@ -3316,7 +4009,6 @@ var EntityManager = class {
3316
4009
  for (const [sourceUrl, forkUrl] of entityUrlMap) {
3317
4010
  stringMap.set(sourceUrl, forkUrl);
3318
4011
  stringMap.set(`${sourceUrl}/main`, `${forkUrl}/main`);
3319
- stringMap.set(`${sourceUrl}/error`, `${forkUrl}/error`);
3320
4012
  }
3321
4013
  for (const [sourceId, forkId] of sharedStateIdMap) {
3322
4014
  stringMap.set(sourceId, forkId);
@@ -3324,7 +4016,7 @@ var EntityManager = class {
3324
4016
  }
3325
4017
  return stringMap;
3326
4018
  }
3327
- buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap) {
4019
+ buildForkEntityPlans(entitiesToFork, entityUrlMap, stringMap, createdBy) {
3328
4020
  const now = Date.now();
3329
4021
  return entitiesToFork.map((source) => {
3330
4022
  const forkUrl = entityUrlMap.get(source.url);
@@ -3337,14 +4029,12 @@ var EntityManager = class {
3337
4029
  url: forkUrl,
3338
4030
  type,
3339
4031
  status: `idle`,
3340
- streams: {
3341
- main: `${forkUrl}/main`,
3342
- error: `${forkUrl}/error`
3343
- },
4032
+ streams: { main: `${forkUrl}/main` },
3344
4033
  subscription_id: `${type}-handler`,
3345
4034
  write_token: randomUUID(),
3346
4035
  spawn_args: spawnArgs,
3347
4036
  parent,
4037
+ created_by: createdBy ?? source.created_by,
3348
4038
  created_at: now,
3349
4039
  updated_at: now
3350
4040
  };
@@ -3578,7 +4268,7 @@ var EntityManager = class {
3578
4268
  }
3579
4269
  async materializeForkManifestSideEffects(entityUrl, manifests) {
3580
4270
  for (const [manifestKey, manifest] of manifests) {
3581
- await this.syncEntitiesManifestSource(entityUrl, manifestKey, `upsert`, manifest);
4271
+ await this.syncManifestLinks(entityUrl, manifestKey, `upsert`, manifest);
3582
4272
  const wake = buildManifestWakeRegistration(entityUrl, manifest, manifestKey);
3583
4273
  if (wake) await this.wakeRegistry.register({
3584
4274
  ...wake,
@@ -3608,6 +4298,7 @@ var EntityManager = class {
3608
4298
  await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
3609
4299
  entityUrl: targetUrl,
3610
4300
  from: senderUrl,
4301
+ from_agent: senderUrl,
3611
4302
  payload: manifest.payload,
3612
4303
  key: `scheduled-${producerId}`,
3613
4304
  type: typeof manifest.messageType === `string` ? manifest.messageType : void 0,
@@ -3647,12 +4338,14 @@ var EntityManager = class {
3647
4338
  const now = new Date().toISOString();
3648
4339
  const key = req.key ?? `msg-in-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3649
4340
  const value = {
3650
- from: req.from,
4341
+ from: req.from_principal ?? req.from,
3651
4342
  payload: req.payload,
3652
4343
  timestamp: now,
3653
4344
  mode: req.mode ?? `immediate`,
3654
4345
  status: req.mode === `queued` || req.mode === `paused` ? `pending` : `processed`
3655
4346
  };
4347
+ if (req.from_principal) value.from_principal = req.from_principal;
4348
+ if (req.from_agent) value.from_agent = req.from_agent;
3656
4349
  if (req.type) value.message_type = req.type;
3657
4350
  if (req.position) value.position = req.position;
3658
4351
  else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
@@ -3824,9 +4517,9 @@ var EntityManager = class {
3824
4517
  if (result.changed && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
3825
4518
  return updated;
3826
4519
  }
3827
- async ensureEntitiesMembershipStream(tags) {
4520
+ async ensureEntitiesMembershipStream(tags, principal) {
3828
4521
  if (!this.entityBridgeManager) throw new Error(`Entity bridge manager not configured`);
3829
- return this.entityBridgeManager.register(this.validateTags(tags));
4522
+ return this.entityBridgeManager.register(this.validateTags(tags), principal.url, principal.kind);
3830
4523
  }
3831
4524
  async writeManifestEntry(entityUrl, key, operation, value, opts) {
3832
4525
  const entity = await this.registry.getEntity(entityUrl);
@@ -3844,11 +4537,11 @@ var EntityManager = class {
3844
4537
  const encoded = this.encodeChangeEvent(event);
3845
4538
  if (opts?.producerId) {
3846
4539
  await this.streamClient.appendIdempotent(entity.streams.main, encoded, { producerId: opts.producerId });
3847
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value);
4540
+ await this.syncManifestLinks(entityUrl, key, operation, value);
3848
4541
  return;
3849
4542
  }
3850
4543
  await this.streamClient.append(entity.streams.main, encoded);
3851
- await this.syncEntitiesManifestSource(entityUrl, key, operation, value);
4544
+ await this.syncManifestLinks(entityUrl, key, operation, value);
3852
4545
  }
3853
4546
  async upsertCronSchedule(entityUrl, req) {
3854
4547
  if (req.payload === void 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Missing required field: payload`, 400);
@@ -3997,6 +4690,8 @@ var EntityManager = class {
3997
4690
  await this.scheduler.enqueueDelayedSend({
3998
4691
  entityUrl,
3999
4692
  from: req.from,
4693
+ from_principal: req.from_principal,
4694
+ from_agent: req.from_agent,
4000
4695
  payload: req.payload,
4001
4696
  key: req.key,
4002
4697
  type: req.type,
@@ -4039,14 +4734,23 @@ var EntityManager = class {
4039
4734
  await this.streamClient.appendIdempotent(subscriber.streams.main, this.encodeChangeEvent(wakeEvent), { producerId: `wake-reg-${result.registrationDbId}-${result.sourceEventKey}` });
4040
4735
  });
4041
4736
  }
4042
- async syncEntitiesManifestSource(entityUrl, manifestKey, operation, value) {
4737
+ async syncManifestLinks(entityUrl, manifestKey, operation, value) {
4043
4738
  const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
4044
4739
  await this.registry.replaceEntityManifestSource(entityUrl, manifestKey, sourceRef);
4740
+ const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
4741
+ await this.registry.replaceSharedStateLink(entityUrl, manifestKey, sharedStateId);
4045
4742
  }
4046
4743
  extractEntitiesSourceRef(manifest) {
4047
4744
  if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
4048
4745
  return void 0;
4049
4746
  }
4747
+ extractSharedStateId(manifest) {
4748
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
4749
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
4750
+ if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
4751
+ const config = isRecord(manifest.config) ? manifest.config : void 0;
4752
+ return typeof config?.id === `string` ? config.id : void 0;
4753
+ }
4050
4754
  /**
4051
4755
  * Read a child entity's stream and extract concatenated text deltas
4052
4756
  * for a specific run, plus any error messages for that run.
@@ -4210,14 +4914,7 @@ var EntityManager = class {
4210
4914
  await this.streamClient.append(entity.streams.main, signalData);
4211
4915
  return;
4212
4916
  }
4213
- const errorCloseEvent = {
4214
- type: `signal`,
4215
- key: signalEvent.key,
4216
- value: signalEvent.value,
4217
- headers: signalEvent.headers
4218
- };
4219
- const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
4220
- for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
4917
+ for (const [streamPath, data] of [[entity.streams.main, signalData]]) try {
4221
4918
  await this.streamClient.append(streamPath, data, { close: true });
4222
4919
  } catch (err) {
4223
4920
  const message = err instanceof Error ? err.message : String(err);
@@ -4307,6 +5004,7 @@ var EntityManager = class {
4307
5004
  streams: entity.streams,
4308
5005
  tags: entity.tags,
4309
5006
  spawnArgs: entity.spawn_args,
5007
+ sandbox: entity.sandbox,
4310
5008
  createdBy: entity.created_by
4311
5009
  },
4312
5010
  principal: principalFromCreatedBy(entity.created_by),
@@ -5199,6 +5897,8 @@ var ElectricAgentsTenantRuntime = class {
5199
5897
  try {
5200
5898
  await this.manager.send(payload.entityUrl, {
5201
5899
  from: payload.from,
5900
+ from_principal: payload.from_principal,
5901
+ from_agent: payload.from_agent,
5202
5902
  payload: payload.payload,
5203
5903
  key: payload.key ?? `scheduled-task-${taskId}`,
5204
5904
  type: payload.type
@@ -5271,6 +5971,7 @@ var ElectricAgentsTenantRuntime = class {
5271
5971
  await this.scheduler.syncManifestDelayedSend(ownerEntityUrl, manifestKey, {
5272
5972
  entityUrl: targetUrl,
5273
5973
  from: senderUrl,
5974
+ from_agent: senderUrl,
5274
5975
  payload: value.payload,
5275
5976
  key: `scheduled-${producerId}`,
5276
5977
  type: typeof value.messageType === `string` ? value.messageType : void 0,
@@ -5295,11 +5996,20 @@ var ElectricAgentsTenantRuntime = class {
5295
5996
  async applyManifestEntitySource(ownerEntityUrl, manifestKey, operation, value) {
5296
5997
  const sourceRef = operation === `delete` ? void 0 : this.extractEntitiesSourceRef(value);
5297
5998
  await this.manager.registry.replaceEntityManifestSource(ownerEntityUrl, manifestKey, sourceRef);
5999
+ const sharedStateId = operation === `delete` ? void 0 : this.extractSharedStateId(value);
6000
+ await this.manager.registry.replaceSharedStateLink(ownerEntityUrl, manifestKey, sharedStateId);
5298
6001
  }
5299
6002
  extractEntitiesSourceRef(manifest) {
5300
6003
  if (manifest?.kind === `source` && manifest.sourceType === `entities` && typeof manifest.sourceRef === `string`) return manifest.sourceRef;
5301
6004
  return void 0;
5302
6005
  }
6006
+ extractSharedStateId(manifest) {
6007
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) return manifest.id;
6008
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) return void 0;
6009
+ if (typeof manifest.sourceRef === `string`) return manifest.sourceRef;
6010
+ const config = typeof manifest.config === `object` && manifest.config !== null && !Array.isArray(manifest.config) ? manifest.config : void 0;
6011
+ return typeof config?.id === `string` ? config.id : void 0;
6012
+ }
5303
6013
  async maybeMarkEntityIdleAfterRunFinished(entityUrl) {
5304
6014
  const primaryStream = `${entityUrl}/main`;
5305
6015
  const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
@@ -5972,11 +6682,21 @@ var WakeRegistry = class {
5972
6682
  }
5973
6683
  const kind = operation === `delete` ? `delete` : operation === `update` ? `update` : `insert`;
5974
6684
  if (condition.ops && condition.ops.length > 0 && !condition.ops.includes(kind)) return null;
5975
- return { change: {
6685
+ const change = {
5976
6686
  collection: eventType,
5977
6687
  kind,
5978
6688
  key: event.key || ``
5979
- } };
6689
+ };
6690
+ if (eventType === `inbox`) {
6691
+ const value = event.value;
6692
+ if (typeof value?.from === `string`) change.from = value.from;
6693
+ if (typeof value?.from_principal === `string`) change.from_principal = value.from_principal;
6694
+ if (typeof value?.from_agent === `string`) change.from_agent = value.from_agent;
6695
+ if (`payload` in (value ?? {})) change.payload = value?.payload;
6696
+ if (typeof value?.timestamp === `string`) change.timestamp = value.timestamp;
6697
+ if (typeof value?.message_type === `string`) change.message_type = value.message_type;
6698
+ }
6699
+ return { change };
5980
6700
  }
5981
6701
  };
5982
6702
 
@@ -6383,29 +7103,136 @@ function buildElectricProxyTarget(options) {
6383
7103
  if (options.electricSecret) target.searchParams.set(`secret`, options.electricSecret);
6384
7104
  const table = options.incomingUrl.searchParams.get(`table`);
6385
7105
  if (table === `entities`) {
6386
- 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"`);
6387
- applyTenantShapeWhere(target, options.tenantId);
7106
+ target.searchParams.set(`columns`, `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","created_by","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`);
7107
+ applyShapeWhere(target, buildReadableEntitiesWhere({
7108
+ tenantId: options.tenantId,
7109
+ principalUrl: options.principalUrl ?? ``,
7110
+ principalKind: options.principalKind ?? ``,
7111
+ permissionBypass: options.permissionBypass
7112
+ }));
6388
7113
  } else if (table === `entity_types`) {
6389
7114
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
6390
- applyTenantShapeWhere(target, options.tenantId);
7115
+ applyShapeWhere(target, buildSpawnableEntityTypesWhere({
7116
+ tenantId: options.tenantId,
7117
+ principalUrl: options.principalUrl ?? ``,
7118
+ principalKind: options.principalKind ?? ``,
7119
+ permissionBypass: options.permissionBypass
7120
+ }));
6391
7121
  } else if (table === `runners`) {
6392
- target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
7122
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`);
6393
7123
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
7124
+ } else if (table === `users`) {
7125
+ target.searchParams.set(`columns`, `"tenant_id","id","display_name","email","avatar_url","created_at","updated_at"`);
7126
+ applyTenantShapeWhere(target, options.tenantId);
7127
+ } else if (table === `entity_effective_permissions`) {
7128
+ target.searchParams.set(`columns`, `"tenant_id","id","entity_url","source_entity_url","source_grant_id","permission","subject_kind","subject_value","expires_at","created_at"`);
7129
+ applyShapeWhere(target, buildCurrentPrincipalEntityEffectivePermissionsWhere({
7130
+ tenantId: options.tenantId,
7131
+ principalUrl: options.principalUrl ?? ``,
7132
+ principalKind: options.principalKind ?? ``,
7133
+ permissionBypass: options.permissionBypass
7134
+ }));
6394
7135
  } else if (table === `runner_runtime_diagnostics`) {
6395
7136
  target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
6396
7137
  applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`]);
6397
7138
  } else if (table === `entity_dispatch_state`) {
6398
7139
  target.searchParams.set(`columns`, `"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`);
6399
- applyTenantShapeWhere(target, options.tenantId);
7140
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7141
+ tenantId: options.tenantId,
7142
+ principalUrl: options.principalUrl ?? ``,
7143
+ principalKind: options.principalKind ?? ``,
7144
+ permissionBypass: options.permissionBypass
7145
+ }));
6400
7146
  } else if (table === `wake_notifications`) {
6401
7147
  target.searchParams.set(`columns`, `"tenant_id","wake_id","entity_url","target_type","target_runner_id","target_webhook_url","target_worker_pool_id","runner_wake_stream","runner_wake_stream_offset","notification_public","delivery_status","claim_status","created_at","delivered_at","claimed_at","resolved_at"`);
6402
- applyTenantShapeWhere(target, options.tenantId);
7148
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7149
+ tenantId: options.tenantId,
7150
+ principalUrl: options.principalUrl ?? ``,
7151
+ principalKind: options.principalKind ?? ``,
7152
+ permissionBypass: options.permissionBypass
7153
+ }));
6403
7154
  } else if (table === `consumer_claims`) {
6404
7155
  target.searchParams.set(`columns`, `"tenant_id","consumer_id","epoch","wake_id","entity_url","stream_path","runner_id","status","claimed_at","last_heartbeat_at","lease_expires_at","released_at","acked_streams","updated_at"`);
6405
- applyTenantShapeWhere(target, options.tenantId);
7156
+ applyShapeWhere(target, buildReadableEntityUrlWhere({
7157
+ tenantId: options.tenantId,
7158
+ principalUrl: options.principalUrl ?? ``,
7159
+ principalKind: options.principalKind ?? ``,
7160
+ permissionBypass: options.permissionBypass
7161
+ }));
6406
7162
  }
6407
7163
  return target;
6408
7164
  }
7165
+ function buildReadableEntitiesWhere(options) {
7166
+ const tenant = sqlStringLiteral(options.tenantId);
7167
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7168
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7169
+ const principalKind = sqlStringLiteral(options.principalKind);
7170
+ return [
7171
+ `tenant_id = ${tenant}`,
7172
+ `AND (`,
7173
+ ` created_by = ${principalUrl$1}`,
7174
+ ` OR url IN (`,
7175
+ ` SELECT entity_url`,
7176
+ ` FROM entity_effective_permissions`,
7177
+ ` WHERE tenant_id = ${tenant}`,
7178
+ ` AND permission IN ('read', 'manage')`,
7179
+ ` AND (`,
7180
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7181
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7182
+ ` )`,
7183
+ ` )`,
7184
+ `)`
7185
+ ].join(`\n`);
7186
+ }
7187
+ function buildReadableEntityUrlWhere(options) {
7188
+ const tenant = sqlStringLiteral(options.tenantId);
7189
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7190
+ return [
7191
+ `tenant_id = ${tenant}`,
7192
+ `AND entity_url IN (`,
7193
+ ` SELECT url`,
7194
+ ` FROM entities`,
7195
+ ` WHERE ${indentWhere(buildReadableEntitiesWhere(options), ` `).trimStart()}`,
7196
+ `)`
7197
+ ].join(`\n`);
7198
+ }
7199
+ function buildCurrentPrincipalEntityEffectivePermissionsWhere(options) {
7200
+ const tenant = sqlStringLiteral(options.tenantId);
7201
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7202
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7203
+ const principalKind = sqlStringLiteral(options.principalKind);
7204
+ return [
7205
+ `tenant_id = ${tenant}`,
7206
+ `AND (`,
7207
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7208
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7209
+ `)`,
7210
+ `AND entity_url IN (`,
7211
+ ` SELECT url`,
7212
+ ` FROM entities`,
7213
+ ` WHERE ${buildReadableEntitiesWhere(options)}`,
7214
+ `)`
7215
+ ].join(`\n`);
7216
+ }
7217
+ function buildSpawnableEntityTypesWhere(options) {
7218
+ const tenant = sqlStringLiteral(options.tenantId);
7219
+ if (options.permissionBypass) return `tenant_id = ${tenant}`;
7220
+ const principalUrl$1 = sqlStringLiteral(options.principalUrl);
7221
+ const principalKind = sqlStringLiteral(options.principalKind);
7222
+ return [
7223
+ `tenant_id = ${tenant}`,
7224
+ `AND name IN (`,
7225
+ ` SELECT entity_type`,
7226
+ ` FROM entity_type_permission_grants`,
7227
+ ` WHERE tenant_id = ${tenant}`,
7228
+ ` AND permission IN ('spawn', 'manage')`,
7229
+ ` AND (`,
7230
+ ` (subject_kind = 'principal' AND subject_value = ${principalUrl$1})`,
7231
+ ` OR (subject_kind = 'principal_kind' AND subject_value = ${principalKind})`,
7232
+ ` )`,
7233
+ `)`
7234
+ ].join(`\n`);
7235
+ }
6409
7236
  async function forwardFetchRequest(options) {
6410
7237
  const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting, options.durableStreamsUrl);
6411
7238
  const routingInput = {
@@ -6440,13 +7267,170 @@ function decodeJsonObject(body) {
6440
7267
  return null;
6441
7268
  }
6442
7269
  function applyTenantShapeWhere(target, tenantId, extraConditions = []) {
6443
- const tenantWhere = [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(` AND `);
7270
+ applyShapeWhere(target, [`tenant_id = ${sqlStringLiteral(tenantId)}`, ...extraConditions].join(` AND `));
7271
+ }
7272
+ function applyShapeWhere(target, enforcedWhere) {
6444
7273
  const existingWhere = target.searchParams.get(`where`);
6445
- target.searchParams.set(`where`, existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere);
7274
+ target.searchParams.set(`where`, existingWhere ? `${enforcedWhere} AND (${existingWhere})` : enforcedWhere);
6446
7275
  }
6447
7276
  function sqlStringLiteral(value) {
6448
7277
  return `'${value.replace(/'/g, `''`)}'`;
6449
7278
  }
7279
+ function indentWhere(where, prefix) {
7280
+ return where.split(`\n`).map((line) => `${prefix}${line}`).join(`\n`);
7281
+ }
7282
+
7283
+ //#endregion
7284
+ //#region src/permissions.ts
7285
+ const authzDecisionCache = new WeakMap();
7286
+ function principalSubject(principal) {
7287
+ return {
7288
+ principalUrl: principal.url,
7289
+ principalKind: principal.kind
7290
+ };
7291
+ }
7292
+ function isPermissionBypassPrincipal(ctx) {
7293
+ return isBuiltInSystemPrincipalUrl(ctx.principal.url);
7294
+ }
7295
+ async function canAccessEntity(ctx, entity, permission, request) {
7296
+ if (isPermissionBypassPrincipal(ctx)) return true;
7297
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7298
+ const builtInAllowed = entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal));
7299
+ return await applyAuthorizationHook(ctx, {
7300
+ verb: permission,
7301
+ resourceKey: `entity:${entity.url}`,
7302
+ resource: {
7303
+ kind: `entity`,
7304
+ entity
7305
+ },
7306
+ builtInAllowed,
7307
+ request
7308
+ });
7309
+ }
7310
+ async function canAccessEntityType(ctx, entityType, permission, request) {
7311
+ if (isPermissionBypassPrincipal(ctx)) return true;
7312
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7313
+ const builtInAllowed = await ctx.entityManager.registry.hasEntityTypePermission(entityType.name, permission, principalSubject(ctx.principal));
7314
+ return await applyAuthorizationHook(ctx, {
7315
+ verb: permission,
7316
+ resourceKey: `entity_type:${entityType.name}`,
7317
+ resource: {
7318
+ kind: `entity_type`,
7319
+ entityType
7320
+ },
7321
+ builtInAllowed,
7322
+ request
7323
+ });
7324
+ }
7325
+ async function canRegisterEntityType(ctx, input, request) {
7326
+ if (isPermissionBypassPrincipal(ctx)) return true;
7327
+ return await applyAuthorizationHook(ctx, {
7328
+ verb: `manage`,
7329
+ resourceKey: `entity_type_registration:${input.name}`,
7330
+ resource: {
7331
+ kind: `entity_type_registration`,
7332
+ entityTypeName: input.name
7333
+ },
7334
+ builtInAllowed: true,
7335
+ request
7336
+ });
7337
+ }
7338
+ async function canAccessSharedState(ctx, sharedStateId, permission, request, ownerEntityUrl) {
7339
+ if (isPermissionBypassPrincipal(ctx)) return true;
7340
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
7341
+ const storedLinkedEntityUrls = await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(sharedStateId);
7342
+ const bootstrapEntityUrls = storedLinkedEntityUrls.length === 0 && ownerEntityUrl ? [ownerEntityUrl] : [];
7343
+ const linkedEntityUrls = [...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls])];
7344
+ for (const entityUrl of linkedEntityUrls) {
7345
+ const entity = await ctx.entityManager.registry.getEntity(entityUrl);
7346
+ if (!entity) continue;
7347
+ if (entity.created_by === ctx.principal.url || await ctx.entityManager.registry.hasEntityPermission(entity.url, permission, principalSubject(ctx.principal))) return await applyAuthorizationHook(ctx, {
7348
+ verb: permission,
7349
+ resourceKey: `shared_state:${sharedStateId}`,
7350
+ resource: {
7351
+ kind: `shared_state`,
7352
+ sharedStateId,
7353
+ linkedEntityUrls
7354
+ },
7355
+ builtInAllowed: true,
7356
+ request
7357
+ });
7358
+ }
7359
+ return await applyAuthorizationHook(ctx, {
7360
+ verb: permission,
7361
+ resourceKey: `shared_state:${sharedStateId}`,
7362
+ resource: {
7363
+ kind: `shared_state`,
7364
+ sharedStateId,
7365
+ linkedEntityUrls
7366
+ },
7367
+ builtInAllowed: false,
7368
+ request
7369
+ });
7370
+ }
7371
+ async function applyAuthorizationHook(ctx, input) {
7372
+ const hook = ctx.authorizeRequest;
7373
+ if (!hook) return input.builtInAllowed;
7374
+ const cacheKey = [
7375
+ ctx.service,
7376
+ ctx.principal.url,
7377
+ input.verb,
7378
+ input.resourceKey
7379
+ ].join(`|`);
7380
+ const cached = getCachedDecision(hook, cacheKey);
7381
+ if (cached) return cached.decision === `allow`;
7382
+ let decision;
7383
+ try {
7384
+ decision = await hook({
7385
+ tenant: ctx.service,
7386
+ principal: ctx.principal,
7387
+ verb: input.verb,
7388
+ resource: input.resource,
7389
+ request: input.request ? requestMetadata(input.request) : void 0,
7390
+ builtInAllowed: input.builtInAllowed
7391
+ });
7392
+ } catch (error) {
7393
+ serverLog.warn(`[agent-server] authorization hook failed:`, error);
7394
+ return false;
7395
+ }
7396
+ cacheDecision(hook, cacheKey, decision);
7397
+ return decision.decision === `allow`;
7398
+ }
7399
+ function getCachedDecision(hook, cacheKey) {
7400
+ const cache = authzDecisionCache.get(hook);
7401
+ const entry = cache?.get(cacheKey);
7402
+ if (!entry) return null;
7403
+ if (entry.expiresAt <= Date.now()) {
7404
+ cache?.delete(cacheKey);
7405
+ return null;
7406
+ }
7407
+ return { decision: entry.decision };
7408
+ }
7409
+ function cacheDecision(hook, cacheKey, decision) {
7410
+ if (!decision.expires_at) return;
7411
+ const expiresAt = Date.parse(decision.expires_at);
7412
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
7413
+ let cache = authzDecisionCache.get(hook);
7414
+ if (!cache) {
7415
+ cache = new Map();
7416
+ authzDecisionCache.set(hook, cache);
7417
+ }
7418
+ cache.set(cacheKey, {
7419
+ decision: decision.decision,
7420
+ expiresAt
7421
+ });
7422
+ }
7423
+ function requestMetadata(request) {
7424
+ const headers = {};
7425
+ request.headers.forEach((value, key) => {
7426
+ headers[key] = value;
7427
+ });
7428
+ return {
7429
+ method: request.method,
7430
+ url: request.url,
7431
+ headers
7432
+ };
7433
+ }
6450
7434
 
6451
7435
  //#endregion
6452
7436
  //#region src/webhook-signing.ts
@@ -6538,6 +7522,7 @@ const subscriptionControlActions = [
6538
7522
  `ack`,
6539
7523
  `release`
6540
7524
  ];
7525
+ const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`;
6541
7526
  const durableStreamsRouter = Router();
6542
7527
  durableStreamsRouter.put(`/__ds/subscriptions/:subscriptionId`, putSubscriptionBase);
6543
7528
  durableStreamsRouter.get(`/__ds/subscriptions/:subscriptionId`, getSubscriptionBase);
@@ -6755,6 +7740,8 @@ async function webhookJwks(_request, ctx) {
6755
7740
  });
6756
7741
  }
6757
7742
  async function streamAppend(request, ctx) {
7743
+ const auth = await authorizeDurableStreamAccess(request, ctx);
7744
+ if (auth) return auth;
6758
7745
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
6759
7746
  request: {
6760
7747
  method: req.method,
@@ -6771,8 +7758,9 @@ async function streamAppend(request, ctx) {
6771
7758
  }));
6772
7759
  }
6773
7760
  async function proxyPassThrough(request, ctx) {
7761
+ const auth = await authorizeDurableStreamAccess(request, ctx);
7762
+ if (auth) return auth;
6774
7763
  const streamPath = new URL(request.url).pathname;
6775
- if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
6776
7764
  const upstream = await forwardToDurableStreams(ctx, request);
6777
7765
  const method = request.method.toUpperCase();
6778
7766
  const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
@@ -6783,6 +7771,51 @@ async function proxyPassThrough(request, ctx) {
6783
7771
  await endTrackedRead?.();
6784
7772
  }
6785
7773
  }
7774
+ async function authorizeDurableStreamAccess(request, ctx) {
7775
+ const method = request.method.toUpperCase();
7776
+ const streamPath = new URL(request.url).pathname;
7777
+ if (method === `GET` || method === `HEAD`) {
7778
+ const registry = ctx.entityManager?.registry;
7779
+ const entity = registry?.getEntityByStream ? await registry.getEntityByStream(streamPath) : null;
7780
+ if (entity) {
7781
+ if (await canAccessEntity(ctx, entity, `read`, request)) return void 0;
7782
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${entity.url}`);
7783
+ }
7784
+ const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath);
7785
+ if (attachmentEntityUrl) {
7786
+ const attachmentEntity = registry?.getEntity ? await registry.getEntity(attachmentEntityUrl) : null;
7787
+ if (!attachmentEntity) return apiError(404, ErrCodeNotFound, `Entity not found`);
7788
+ if (await canAccessEntity(ctx, attachmentEntity, `read`, request)) return void 0;
7789
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read ${attachmentEntity.url}`);
7790
+ }
7791
+ }
7792
+ const sharedStateId = sharedStateIdFromPath(streamPath);
7793
+ if (!sharedStateId) return void 0;
7794
+ if (method === `GET` || method === `HEAD`) {
7795
+ if (await canAccessSharedState(ctx, sharedStateId, `read`, request)) return void 0;
7796
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to read shared state`);
7797
+ }
7798
+ if (method === `PUT` || method === `POST`) {
7799
+ const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
7800
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
7801
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
7802
+ }
7803
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
7804
+ }
7805
+ function entityUrlFromAttachmentStreamPath(path$1) {
7806
+ const match = path$1.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/);
7807
+ if (!match) return null;
7808
+ return `/${match[1]}/${match[2]}`;
7809
+ }
7810
+ function sharedStateIdFromPath(path$1) {
7811
+ const match = path$1.match(/^\/_electric\/shared-state\/([^/]+)$/);
7812
+ if (!match) return null;
7813
+ try {
7814
+ return decodeURIComponent(match[1]);
7815
+ } catch {
7816
+ return match[1];
7817
+ }
7818
+ }
6786
7819
 
6787
7820
  //#endregion
6788
7821
  //#region src/routing/electric-proxy-router.ts
@@ -6790,12 +7823,15 @@ const electricProxyRouter = Router({ base: `/_electric/electric` });
6790
7823
  electricProxyRouter.get(`/*`, proxyElectric);
6791
7824
  async function proxyElectric(request, ctx) {
6792
7825
  if (!ctx.electricUrl) return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`);
7826
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.();
6793
7827
  const target = buildElectricProxyTarget({
6794
7828
  incomingUrl: new URL(request.url),
6795
7829
  electricUrl: ctx.electricUrl,
6796
7830
  electricSecret: ctx.electricSecret,
6797
7831
  tenantId: ctx.service,
6798
- principalUrl: ctx.principal.url
7832
+ principalUrl: ctx.principal.url,
7833
+ principalKind: ctx.principal.kind,
7834
+ permissionBypass: isPermissionBypassPrincipal(ctx)
6799
7835
  });
6800
7836
  const headers = new Headers(request.headers);
6801
7837
  headers.delete(`host`);
@@ -6815,6 +7851,28 @@ async function proxyElectric(request, ctx) {
6815
7851
  });
6816
7852
  }
6817
7853
 
7854
+ //#endregion
7855
+ //#region src/sandbox-choice-schema.ts
7856
+ /**
7857
+ * Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
7858
+ * the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
7859
+ * persisted on the entity. The matching `SandboxChoice` type is hand-maintained
7860
+ * in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
7861
+ * the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
7862
+ *
7863
+ * Validation happens once, at the router boundary (this schema is embedded in
7864
+ * the spawn body schema); the spawn resolver consumes already-validated input,
7865
+ * so there is intentionally no separate `parse` helper here.
7866
+ */
7867
+ const sandboxChoiceSchema = Type.Object({
7868
+ profile: Type.Optional(Type.String()),
7869
+ key: Type.Optional(Type.String()),
7870
+ scope: Type.Optional(Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])),
7871
+ persistent: Type.Optional(Type.Boolean()),
7872
+ owner: Type.Optional(Type.Boolean()),
7873
+ inherit: Type.Optional(Type.Boolean())
7874
+ });
7875
+
6818
7876
  //#endregion
6819
7877
  //#region src/routing/entities-router.ts
6820
7878
  const stringRecordSchema$1 = Type.Record(Type.String(), Type.String());
@@ -6832,12 +7890,35 @@ const wakeConditionSchema = Type.Union([Type.Literal(`runFinished`), Type.Object
6832
7890
  Type.Literal(`delete`)
6833
7891
  ])))
6834
7892
  })]);
7893
+ const permissionSubjectSchema = Type.Object({
7894
+ subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
7895
+ subject_value: Type.String()
7896
+ }, { additionalProperties: false });
7897
+ const entityPermissionSchema = Type.Union([
7898
+ Type.Literal(`read`),
7899
+ Type.Literal(`write`),
7900
+ Type.Literal(`delete`),
7901
+ Type.Literal(`signal`),
7902
+ Type.Literal(`fork`),
7903
+ Type.Literal(`schedule`),
7904
+ Type.Literal(`spawn`),
7905
+ Type.Literal(`manage`)
7906
+ ]);
7907
+ const entityPermissionGrantInputSchema = Type.Object({
7908
+ ...permissionSubjectSchema.properties,
7909
+ permission: entityPermissionSchema,
7910
+ propagation: Type.Optional(Type.Union([Type.Literal(`self`), Type.Literal(`descendants`)])),
7911
+ copy_to_children: Type.Optional(Type.Boolean()),
7912
+ expires_at: Type.Optional(Type.String())
7913
+ }, { additionalProperties: false });
6835
7914
  const spawnBodySchema = Type.Object({
6836
7915
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
6837
7916
  tags: Type.Optional(stringRecordSchema$1),
6838
7917
  parent: Type.Optional(Type.String()),
6839
7918
  dispatch_policy: Type.Optional(dispatchPolicySchema),
7919
+ sandbox: Type.Optional(sandboxChoiceSchema),
6840
7920
  initialMessage: Type.Optional(Type.Unknown()),
7921
+ grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
6841
7922
  wake: Type.Optional(Type.Object({
6842
7923
  subscriberUrl: Type.String(),
6843
7924
  condition: wakeConditionSchema,
@@ -6859,8 +7940,22 @@ const sendBodySchema = Type.Object({
6859
7940
  ])),
6860
7941
  position: Type.Optional(Type.String()),
6861
7942
  afterMs: Type.Optional(Type.Number()),
6862
- from: Type.Optional(Type.String())
7943
+ from: Type.Optional(Type.String()),
7944
+ from_principal: Type.Optional(Type.String()),
7945
+ from_agent: Type.Optional(Type.String())
6863
7946
  });
7947
+ function agentUrlForPrincipal(principal) {
7948
+ if (principal.kind === `agent`) return `/${principal.id}`;
7949
+ if (principal.key.startsWith(`entity:`)) return `/${principal.key.slice(`entity:`.length)}`;
7950
+ return null;
7951
+ }
7952
+ function agentUrlPath(value) {
7953
+ try {
7954
+ return new URL(value).pathname;
7955
+ } catch {
7956
+ return value;
7957
+ }
7958
+ }
6864
7959
  const inboxMessageBodySchema = Type.Object({
6865
7960
  payload: Type.Optional(Type.Unknown()),
6866
7961
  position: Type.Optional(Type.String()),
@@ -6878,7 +7973,11 @@ const inboxMessageBodySchema = Type.Object({
6878
7973
  });
6879
7974
  const forkBodySchema = Type.Object({
6880
7975
  instance_id: Type.Optional(Type.String()),
6881
- waitTimeoutMs: Type.Optional(Type.Number())
7976
+ waitTimeoutMs: Type.Optional(Type.Number()),
7977
+ fork_pointer: Type.Optional(Type.Object({
7978
+ offset: Type.Union([Type.String(), Type.Null()]),
7979
+ sub_offset: Type.Number()
7980
+ }))
6882
7981
  });
6883
7982
  const setTagBodySchema = Type.Object({ value: Type.String() });
6884
7983
  const entitySignalSchema = Type.Union([
@@ -6935,24 +8034,27 @@ const attachmentSubjectTypes = new Set([
6935
8034
  ]);
6936
8035
  const entitiesRouter = Router({ base: `/_electric/entities` });
6937
8036
  entitiesRouter.get(`/`, listEntities);
6938
- entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
6939
- entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
6940
- entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
6941
- entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
6942
- entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
6943
- entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
6944
- entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
6945
- entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
6946
- entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
6947
- entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
6948
- entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
6949
- entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
6950
- entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), setTag);
6951
- entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, deleteTag);
6952
- entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
6953
- entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
6954
- entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
6955
- entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
8037
+ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), withSpawnPermission, spawnEntity);
8038
+ entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), getEntity);
8039
+ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`read`), headEntity);
8040
+ entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, withEntityPermission(`delete`), killEntity);
8041
+ entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), withEntityPermission(`signal`), signalEntity);
8042
+ entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), withEntityPermission(`write`), sendEntity);
8043
+ entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, withEntityPermission(`write`), createAttachment);
8044
+ entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`read`), readAttachment);
8045
+ entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, withEntityPermission(`write`), deleteAttachment);
8046
+ entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), withEntityPermission(`write`), updateInboxMessage);
8047
+ entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withEntityPermission(`write`), deleteInboxMessage);
8048
+ entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), withEntityPermission(`fork`), forkEntity);
8049
+ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withSchema(setTagBodySchema), withEntityPermission(`write`), setTag);
8050
+ entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withEntityPermission(`write`), deleteTag);
8051
+ entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), withEntityPermission(`schedule`), upsertSchedule);
8052
+ entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withEntityPermission(`schedule`), deleteSchedule);
8053
+ entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), withEntityPermission(`write`), upsertEventSourceSubscription);
8054
+ entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withEntityPermission(`write`), deleteEventSourceSubscription);
8055
+ entitiesRouter.get(`/:type/:instanceId/grants`, withExistingEntity, withEntityPermission(`manage`), listEntityPermissionGrants);
8056
+ entitiesRouter.post(`/:type/:instanceId/grants`, withExistingEntity, withSchema(entityPermissionGrantInputSchema), withEntityPermission(`manage`), createEntityPermissionGrant);
8057
+ entitiesRouter.delete(`/:type/:instanceId/grants/:grantId`, withExistingEntity, withEntityPermission(`manage`), deleteEntityPermissionGrant);
6956
8058
  function entityUrlFromSegments(type, instanceId) {
6957
8059
  if (!type || !instanceId) return null;
6958
8060
  if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
@@ -7051,6 +8153,17 @@ function rejectPrincipalEntityMutation(request, action) {
7051
8153
  if (entity.type !== `principal`) return void 0;
7052
8154
  return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be ${action}`);
7053
8155
  }
8156
+ function parseExpiresAt$1(value) {
8157
+ if (value === void 0) return void 0;
8158
+ const expiresAt = new Date(value);
8159
+ if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8160
+ return expiresAt;
8161
+ }
8162
+ function parseGrantId$1(request) {
8163
+ const grantId = Number.parseInt(String(request.params.grantId), 10);
8164
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
8165
+ return grantId;
8166
+ }
7054
8167
  async function withExistingEntity(request, ctx) {
7055
8168
  const entityUrl = entityUrlFromSegments(request.params.type, request.params.instanceId);
7056
8169
  if (!entityUrl) return void 0;
@@ -7081,17 +8194,76 @@ async function withSpawnableEntityType(request, ctx) {
7081
8194
  if (request.params.type === `principal`) return apiError(400, ErrCodeInvalidRequest, `Principal entities are built in and cannot be spawned directly`);
7082
8195
  const entityType = await ctx.entityManager.registry.getEntityType(request.params.type);
7083
8196
  if (!entityType) return apiError(404, ErrCodeUnknownEntityType, `Entity type "${request.params.type}" not found`);
8197
+ request.spawnRoute = { entityType };
7084
8198
  return void 0;
7085
8199
  }
8200
+ function withEntityPermission(permission) {
8201
+ return async (request, ctx) => {
8202
+ const { entity } = requireExistingEntityRoute(request);
8203
+ if (await canAccessEntity(ctx, entity, permission, request)) return void 0;
8204
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to ${permission} ${entity.url}`);
8205
+ };
8206
+ }
8207
+ async function withSpawnPermission(request, ctx) {
8208
+ const parsed = routeBody(request);
8209
+ const entityType = request.spawnRoute?.entityType;
8210
+ if (!entityType) throw new Error(`spawnable entity type middleware did not run`);
8211
+ if (!await canAccessEntityType(ctx, entityType, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
8212
+ if (!parsed.parent) return void 0;
8213
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
8214
+ if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
8215
+ if (await canAccessEntity(ctx, parent, `spawn`, request)) return await validateParentedSpawnGrants(request, ctx, parent, parsed);
8216
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
8217
+ }
8218
+ async function validateParentedSpawnGrants(request, ctx, parent, parsed) {
8219
+ const needsParentManage = (parsed.grants ?? []).some(requiresParentManageForInitialGrant);
8220
+ if (!needsParentManage) return void 0;
8221
+ if (await canAccessEntity(ctx, parent, `manage`, request)) return void 0;
8222
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to delegate broad grants from ${parent.url}`);
8223
+ }
8224
+ function requiresParentManageForInitialGrant(grant) {
8225
+ return grant.permission === `manage` || grant.subject_kind === `principal_kind` || grant.propagation === `descendants` || grant.copy_to_children === true;
8226
+ }
7086
8227
  async function listEntities({ query }, ctx) {
7087
8228
  const { entities: entities$1 } = await ctx.entityManager.registry.listEntities({
7088
8229
  type: firstQueryValue$1(query.type),
7089
8230
  status: firstQueryValue$1(query.status),
7090
8231
  parent: firstQueryValue$1(query.parent),
7091
- created_by: firstQueryValue$1(query.created_by)
8232
+ created_by: firstQueryValue$1(query.created_by),
8233
+ readableBy: {
8234
+ ...principalSubject(ctx.principal),
8235
+ bypass: isPermissionBypassPrincipal(ctx)
8236
+ }
7092
8237
  });
7093
8238
  return json(entities$1.map((entity) => toPublicEntity(entity)));
7094
8239
  }
8240
+ async function listEntityPermissionGrants(request, ctx) {
8241
+ const { entityUrl } = requireExistingEntityRoute(request);
8242
+ const grants = await ctx.entityManager.registry.listEntityPermissionGrants(entityUrl);
8243
+ return json({ grants });
8244
+ }
8245
+ async function createEntityPermissionGrant(request, ctx) {
8246
+ const { entityUrl } = requireExistingEntityRoute(request);
8247
+ const parsed = routeBody(request);
8248
+ const grant = await ctx.entityManager.registry.createEntityPermissionGrant({
8249
+ entityUrl,
8250
+ permission: parsed.permission,
8251
+ subjectKind: parsed.subject_kind,
8252
+ subjectValue: parsed.subject_value,
8253
+ propagation: parsed.propagation,
8254
+ copyToChildren: parsed.copy_to_children,
8255
+ expiresAt: parseExpiresAt$1(parsed.expires_at),
8256
+ createdBy: ctx.principal.url
8257
+ });
8258
+ await ctx.entityBridgeManager.onEntityChanged(entityUrl);
8259
+ return json(grant, { status: 201 });
8260
+ }
8261
+ async function deleteEntityPermissionGrant(request, ctx) {
8262
+ const { entityUrl } = requireExistingEntityRoute(request);
8263
+ const deleted = await ctx.entityManager.registry.deleteEntityPermissionGrant(entityUrl, parseGrantId$1(request));
8264
+ if (deleted) await ctx.entityBridgeManager.onEntityChanged(entityUrl);
8265
+ return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
8266
+ }
7095
8267
  async function upsertSchedule(request, ctx) {
7096
8268
  const principalMutationError = rejectPrincipalEntityMutation(request, `scheduled`);
7097
8269
  if (principalMutationError) return principalMutationError;
@@ -7196,7 +8368,12 @@ async function forkEntity(request, ctx) {
7196
8368
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
7197
8369
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
7198
8370
  rootInstanceId: parsed.instance_id,
7199
- waitTimeoutMs: parsed.waitTimeoutMs
8371
+ waitTimeoutMs: parsed.waitTimeoutMs,
8372
+ createdBy: ctx.principal.url,
8373
+ ...parsed.fork_pointer && { forkPointer: {
8374
+ offset: parsed.fork_pointer.offset,
8375
+ subOffset: parsed.fork_pointer.sub_offset
8376
+ } }
7200
8377
  });
7201
8378
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
7202
8379
  return json({
@@ -7208,26 +8385,27 @@ async function sendEntity(request, ctx) {
7208
8385
  const parsed = routeBody(request);
7209
8386
  const principal = ctx.principal;
7210
8387
  if (parsed.from !== void 0 && parsed.from !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from must match Electric-Principal`);
8388
+ if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
8389
+ if (parsed.from_agent !== void 0) {
8390
+ const principalAgentUrl = agentUrlForPrincipal(principal);
8391
+ if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8392
+ }
7211
8393
  await ctx.entityManager.ensurePrincipal(principal);
7212
8394
  const { entityUrl, entity } = requireExistingEntityRoute(request);
7213
8395
  const dispatchEntity = entity.dispatch_policy ? entity : await backfillEntityDispatchPolicy(ctx, entity);
7214
8396
  await linkEntityDispatchSubscription(ctx, dispatchEntity);
7215
- if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, {
7216
- from: principal.url,
7217
- payload: parsed.payload,
7218
- key: parsed.key,
7219
- type: parsed.type,
7220
- mode: parsed.mode,
7221
- position: parsed.position
7222
- }, new Date(Date.now() + parsed.afterMs));
7223
- else await ctx.entityManager.send(entityUrl, {
8397
+ const sendReq = {
7224
8398
  from: principal.url,
8399
+ from_principal: principal.url,
8400
+ from_agent: parsed.from_agent,
7225
8401
  payload: parsed.payload,
7226
8402
  key: parsed.key,
7227
8403
  type: parsed.type,
7228
8404
  mode: parsed.mode,
7229
8405
  position: parsed.position
7230
- });
8406
+ };
8407
+ if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, sendReq, new Date(Date.now() + parsed.afterMs));
8408
+ else await ctx.entityManager.send(entityUrl, sendReq);
7231
8409
  return status(204);
7232
8410
  }
7233
8411
  async function createAttachment(request, ctx) {
@@ -7294,10 +8472,22 @@ async function spawnEntity(request, ctx) {
7294
8472
  tags: parsed.tags,
7295
8473
  parent: parsed.parent,
7296
8474
  dispatch_policy: dispatchPolicy,
8475
+ sandbox: parsed.sandbox,
7297
8476
  initialMessage: void 0,
7298
8477
  wake: parsed.wake,
7299
8478
  created_by: principal.url
7300
8479
  });
8480
+ if (parsed.parent) await ctx.entityManager.registry.copyEntityPermissionGrantsForSpawn(parsed.parent, entity.url, principal.url);
8481
+ for (const grant of parsed.grants ?? []) await ctx.entityManager.registry.createEntityPermissionGrant({
8482
+ entityUrl: entity.url,
8483
+ permission: grant.permission,
8484
+ subjectKind: grant.subject_kind,
8485
+ subjectValue: grant.subject_value,
8486
+ propagation: grant.propagation,
8487
+ copyToChildren: grant.copy_to_children,
8488
+ expiresAt: parseExpiresAt$1(grant.expires_at),
8489
+ createdBy: principal.url
8490
+ });
7301
8491
  const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
7302
8492
  if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
7303
8493
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
@@ -7349,6 +8539,12 @@ async function signalEntity(request, ctx) {
7349
8539
  //#region src/routing/entity-types-router.ts
7350
8540
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown());
7351
8541
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema);
8542
+ const typePermissionGrantInputSchema = Type.Object({
8543
+ subject_kind: Type.Union([Type.Literal(`principal`), Type.Literal(`principal_kind`)]),
8544
+ subject_value: Type.String(),
8545
+ permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
8546
+ expires_at: Type.Optional(Type.String())
8547
+ }, { additionalProperties: false });
7352
8548
  const registerEntityTypeBodySchema = Type.Object({
7353
8549
  name: Type.Optional(Type.String()),
7354
8550
  description: Type.Optional(Type.String()),
@@ -7356,7 +8552,8 @@ const registerEntityTypeBodySchema = Type.Object({
7356
8552
  inbox_schemas: Type.Optional(schemaMapSchema),
7357
8553
  state_schemas: Type.Optional(schemaMapSchema),
7358
8554
  serve_endpoint: Type.Optional(Type.String()),
7359
- default_dispatch_policy: Type.Optional(dispatchPolicySchema)
8555
+ default_dispatch_policy: Type.Optional(dispatchPolicySchema),
8556
+ permission_grants: Type.Optional(Type.Array(typePermissionGrantInputSchema))
7360
8557
  }, { additionalProperties: false });
7361
8558
  const amendEntityTypeSchemasBodySchema = Type.Object({
7362
8559
  inbox_schemas: Type.Optional(schemaMapSchema),
@@ -7364,20 +8561,56 @@ const amendEntityTypeSchemasBodySchema = Type.Object({
7364
8561
  }, { additionalProperties: false });
7365
8562
  const entityTypesRouter = Router({ base: `/_electric/entity-types` });
7366
8563
  entityTypesRouter.get(`/`, listEntityTypes);
7367
- entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), registerEntityType);
7368
- entityTypesRouter.patch(`/:name/schemas`, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
7369
- entityTypesRouter.get(`/:name`, getEntityType);
7370
- entityTypesRouter.delete(`/:name`, deleteEntityType);
8564
+ entityTypesRouter.post(`/`, withSchema(registerEntityTypeBodySchema), withEntityTypeRegistrationPermission, registerEntityType);
8565
+ entityTypesRouter.patch(`/:name/schemas`, withExistingEntityType, withEntityTypeManagePermission, withSchema(amendEntityTypeSchemasBodySchema), amendSchemas);
8566
+ entityTypesRouter.get(`/:name`, withExistingEntityType, withEntityTypeSpawnPermission, getEntityType);
8567
+ entityTypesRouter.delete(`/:name`, withExistingEntityType, withEntityTypeManagePermission, deleteEntityType);
8568
+ entityTypesRouter.get(`/:name/grants`, withExistingEntityType, withEntityTypeManagePermission, listTypePermissionGrants);
8569
+ entityTypesRouter.post(`/:name/grants`, withExistingEntityType, withSchema(typePermissionGrantInputSchema), withEntityTypeManagePermission, createTypePermissionGrant);
8570
+ entityTypesRouter.delete(`/:name/grants/:grantId`, withExistingEntityType, withEntityTypeManagePermission, deleteTypePermissionGrant);
7371
8571
  async function registerEntityType(request, ctx) {
7372
8572
  const parsed = routeBody(request);
7373
8573
  const normalized = normalizeEntityTypeRequest(parsed);
7374
8574
  if (normalized.serve_endpoint && !normalized.description && !normalized.creation_schema) return await discoverServeEndpoint(ctx, normalized);
7375
8575
  const entityType = await ctx.entityManager.registerEntityType(normalized);
8576
+ await applyRegistrationPermissionGrants(ctx, entityType.name, normalized);
7376
8577
  return json(toPublicEntityType(entityType), { status: 201 });
7377
8578
  }
7378
8579
  async function listEntityTypes(_request, ctx) {
7379
8580
  const entityTypes$1 = await ctx.entityManager.registry.listEntityTypes();
7380
- return json(entityTypes$1.map((entityType) => toPublicEntityType(entityType)));
8581
+ const visible = [];
8582
+ for (const entityType of entityTypes$1) if (await canAccessEntityType(ctx, entityType, `spawn`)) visible.push(entityType);
8583
+ return json(visible.map((entityType) => toPublicEntityType(entityType)));
8584
+ }
8585
+ async function withExistingEntityType(request, ctx) {
8586
+ const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
8587
+ if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
8588
+ request.entityTypeRoute = { entityType };
8589
+ return void 0;
8590
+ }
8591
+ async function withEntityTypeManagePermission(request, ctx) {
8592
+ const entityType = request.entityTypeRoute?.entityType;
8593
+ if (!entityType) throw new Error(`entity type middleware did not run`);
8594
+ if (await canAccessEntityType(ctx, entityType, `manage`, request)) return void 0;
8595
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${entityType.name}`);
8596
+ }
8597
+ async function withEntityTypeSpawnPermission(request, ctx) {
8598
+ const entityType = request.entityTypeRoute?.entityType;
8599
+ if (!entityType) throw new Error(`entity type middleware did not run`);
8600
+ if (await canAccessEntityType(ctx, entityType, `spawn`, request)) return void 0;
8601
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn ${entityType.name}`);
8602
+ }
8603
+ async function withEntityTypeRegistrationPermission(request, ctx) {
8604
+ const parsed = normalizeEntityTypeRequest(routeBody(request));
8605
+ if (!parsed.name) return void 0;
8606
+ const existing = await ctx.entityManager.registry.getEntityType(parsed.name);
8607
+ if (existing) {
8608
+ request.entityTypeRoute = { entityType: existing };
8609
+ if (await canAccessEntityType(ctx, existing, `manage`, request)) return void 0;
8610
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to manage ${existing.name}`);
8611
+ }
8612
+ if (await canRegisterEntityType(ctx, parsed, request)) return void 0;
8613
+ return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to register entity types`);
7381
8614
  }
7382
8615
  async function discoverServeEndpoint(ctx, parsed) {
7383
8616
  try {
@@ -7386,17 +8619,17 @@ async function discoverServeEndpoint(ctx, parsed) {
7386
8619
  const manifest = await response.json();
7387
8620
  if (manifest.name !== parsed.name) return apiError(400, ErrCodeServeEndpointNameMismatch, `Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`);
7388
8621
  manifest.serve_endpoint = parsed.serve_endpoint;
8622
+ manifest.permission_grants = parsed.permission_grants;
7389
8623
  const entityType = await ctx.entityManager.registerEntityType(normalizeEntityTypeRequest(manifest));
8624
+ await applyRegistrationPermissionGrants(ctx, entityType.name, manifest);
7390
8625
  return json(toPublicEntityType(entityType), { status: 201 });
7391
8626
  } catch (err) {
7392
8627
  if (err instanceof ElectricAgentsError) throw err;
7393
8628
  return apiError(502, ErrCodeServeEndpointUnreachable, `Failed to reach serve endpoint: ${err instanceof Error ? err.message : String(err)}`);
7394
8629
  }
7395
8630
  }
7396
- async function getEntityType(request, ctx) {
7397
- const entityType = await ctx.entityManager.registry.getEntityType(request.params.name);
7398
- if (!entityType) return apiError(404, ErrCodeNotFound, `Entity type not found`);
7399
- return json(toPublicEntityType(entityType));
8631
+ async function getEntityType(request) {
8632
+ return json(toPublicEntityType(request.entityTypeRoute.entityType));
7400
8633
  }
7401
8634
  async function amendSchemas(request, ctx) {
7402
8635
  const parsed = routeBody(request);
@@ -7410,6 +8643,47 @@ async function deleteEntityType(request, ctx) {
7410
8643
  await ctx.entityManager.deleteEntityType(request.params.name);
7411
8644
  return status(204);
7412
8645
  }
8646
+ async function listTypePermissionGrants(request, ctx) {
8647
+ const grants = await ctx.entityManager.registry.listEntityTypePermissionGrants(request.entityTypeRoute.entityType.name);
8648
+ return json({ grants });
8649
+ }
8650
+ async function createTypePermissionGrant(request, ctx) {
8651
+ const parsed = routeBody(request);
8652
+ const grant = await ctx.entityManager.registry.createEntityTypePermissionGrant({
8653
+ entityType: request.entityTypeRoute.entityType.name,
8654
+ permission: parsed.permission,
8655
+ subjectKind: parsed.subject_kind,
8656
+ subjectValue: parsed.subject_value,
8657
+ expiresAt: parseExpiresAt(parsed.expires_at),
8658
+ createdBy: ctx.principal.url
8659
+ });
8660
+ return json(grant, { status: 201 });
8661
+ }
8662
+ async function deleteTypePermissionGrant(request, ctx) {
8663
+ const deleted = await ctx.entityManager.registry.deleteEntityTypePermissionGrant(request.entityTypeRoute.entityType.name, parseGrantId(request));
8664
+ return deleted ? status(204) : apiError(404, ErrCodeNotFound, `Grant not found`);
8665
+ }
8666
+ async function applyRegistrationPermissionGrants(ctx, entityType, request) {
8667
+ for (const grant of request.permission_grants ?? []) await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
8668
+ entityType,
8669
+ permission: grant.permission,
8670
+ subjectKind: grant.subject_kind,
8671
+ subjectValue: grant.subject_value,
8672
+ expiresAt: parseExpiresAt(grant.expires_at),
8673
+ createdBy: ctx.principal.url
8674
+ });
8675
+ }
8676
+ function parseGrantId(request) {
8677
+ const grantId = Number.parseInt(String(request.params.grantId), 10);
8678
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid grant id`, 400);
8679
+ return grantId;
8680
+ }
8681
+ function parseExpiresAt(value) {
8682
+ if (value === void 0) return void 0;
8683
+ const expiresAt = new Date(value);
8684
+ if (Number.isNaN(expiresAt.getTime())) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Invalid expires_at timestamp`, 400);
8685
+ return expiresAt;
8686
+ }
7413
8687
  function normalizeEntityTypeRequest(parsed) {
7414
8688
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint);
7415
8689
  return {
@@ -7422,7 +8696,8 @@ function normalizeEntityTypeRequest(parsed) {
7422
8696
  default_dispatch_policy: parsed.default_dispatch_policy ?? (serveEndpoint ? { targets: [{
7423
8697
  type: `webhook`,
7424
8698
  url: serveEndpoint
7425
- }] } : void 0)
8699
+ }] } : void 0),
8700
+ permission_grants: parsed.permission_grants
7426
8701
  };
7427
8702
  }
7428
8703
  function toPublicEntityType(entityType) {
@@ -7481,6 +8756,7 @@ function applyCors(response) {
7481
8756
  `content-type`,
7482
8757
  `authorization`,
7483
8758
  `electric-claim-token`,
8759
+ `electric-owner-entity`,
7484
8760
  ELECTRIC_PRINCIPAL_HEADER,
7485
8761
  `ngrok-skip-browser-warning`
7486
8762
  ].join(`, `));
@@ -7531,7 +8807,7 @@ observationsRouter.post(`/entities/ensure-stream`, withSchema(ensureEntitiesMemb
7531
8807
  observationsRouter.post(`/cron/ensure-stream`, withSchema(ensureCronStreamBodySchema), ensureCronStream);
7532
8808
  async function ensureEntitiesMembershipStream(request, ctx) {
7533
8809
  const parsed = routeBody(request);
7534
- const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {});
8810
+ const result = await ctx.entityManager.ensureEntitiesMembershipStream(parsed.tags ?? {}, ctx.principal);
7535
8811
  return json(result);
7536
8812
  }
7537
8813
  async function ensureCronStream(request, ctx) {
@@ -7548,6 +8824,12 @@ function withLeadingSlash(path$1) {
7548
8824
 
7549
8825
  //#endregion
7550
8826
  //#region src/routing/runners-router.ts
8827
+ const sandboxProfileBodySchema = Type.Object({
8828
+ name: Type.String(),
8829
+ label: Type.String(),
8830
+ description: Type.Optional(Type.String()),
8831
+ remote: Type.Optional(Type.Boolean())
8832
+ });
7551
8833
  const registerRunnerBodySchema = Type.Object({
7552
8834
  id: Type.String(),
7553
8835
  owner_principal: Type.Optional(Type.String()),
@@ -7560,7 +8842,8 @@ const registerRunnerBodySchema = Type.Object({
7560
8842
  Type.Literal(`server`)
7561
8843
  ])),
7562
8844
  admin_status: Type.Optional(Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])),
7563
- wake_stream: Type.Optional(Type.String())
8845
+ wake_stream: Type.Optional(Type.String()),
8846
+ sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema))
7564
8847
  });
7565
8848
  const heartbeatBodySchema = Type.Object({
7566
8849
  lease_ms: Type.Optional(Type.Number()),
@@ -7658,7 +8941,8 @@ async function registerRunner(request, ctx) {
7658
8941
  label: parsed.label,
7659
8942
  kind: parsed.kind,
7660
8943
  adminStatus: parsed.admin_status,
7661
- wakeStream: parsed.wake_stream
8944
+ wakeStream: parsed.wake_stream,
8945
+ sandboxProfiles: parsed.sandbox_profiles
7662
8946
  });
7663
8947
  await ctx.streamClient.ensure(runner.wake_stream, { contentType: `application/json` });
7664
8948
  return json(runner, { status: 201 });
@@ -7888,6 +9172,7 @@ async function notificationFromClaim(ctx, input) {
7888
9172
  streams: entity.streams,
7889
9173
  tags: entity.tags,
7890
9174
  spawnArgs: entity.spawn_args,
9175
+ sandbox: entity.sandbox,
7891
9176
  createdBy: entity.created_by
7892
9177
  },
7893
9178
  principal: principalFromCreatedBy(entity.created_by)