@electric-ax/agents-server 0.4.15 → 0.4.16

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