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