@electric-ax/agents-server 0.4.15 → 0.4.17

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.
@@ -8,22 +8,27 @@ import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
8
8
  import { ElectricAgentsError } from '../entity-manager.js'
9
9
  import {
10
10
  ErrCodeNotFound,
11
+ ErrCodeInvalidRequest,
12
+ ErrCodeUnauthorized,
11
13
  ErrCodeServeEndpointNameMismatch,
12
14
  ErrCodeServeEndpointUnreachable,
13
15
  } from '../electric-agents-types.js'
14
16
  import { apiError } from '../electric-agents-http.js'
15
17
  import { routeBody, withSchema } from './schema.js'
16
18
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
19
+ import { canAccessEntityType, canRegisterEntityType } from '../permissions.js'
17
20
  import type {
18
21
  ElectricAgentsEntityType,
19
22
  RegisterEntityTypeRequest,
23
+ EntityTypePermissionGrantInput,
20
24
  } from '../electric-agents-types.js'
21
25
  import type { JsonRouteRequest } from './schema.js'
22
26
  import type { RouterType } from 'itty-router'
23
27
  import type { TenantContext } from './context.js'
24
28
 
25
- export interface ElectricAgentsEntityTypeRouteRequest
26
- extends JsonRouteRequest {}
29
+ export interface ElectricAgentsEntityTypeRouteRequest extends JsonRouteRequest {
30
+ entityTypeRoute?: { entityType: ElectricAgentsEntityType }
31
+ }
27
32
 
28
33
  type EntityTypeRouteArgs = [TenantContext]
29
34
  type EntityTypeRouteResult = Response | undefined
@@ -40,6 +45,40 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
40
45
 
41
46
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
42
47
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
48
+ const slashCommandArgumentSchema = Type.Object(
49
+ {
50
+ name: Type.String(),
51
+ type: Type.Union([
52
+ Type.Literal(`string`),
53
+ Type.Literal(`number`),
54
+ Type.Literal(`boolean`),
55
+ ]),
56
+ required: Type.Optional(Type.Boolean()),
57
+ description: Type.Optional(Type.String()),
58
+ },
59
+ { additionalProperties: false }
60
+ )
61
+ const slashCommandSchema = Type.Object(
62
+ {
63
+ name: Type.String(),
64
+ description: Type.Optional(Type.String()),
65
+ arguments: Type.Optional(Type.Array(slashCommandArgumentSchema)),
66
+ },
67
+ { additionalProperties: false }
68
+ )
69
+
70
+ const typePermissionGrantInputSchema = Type.Object(
71
+ {
72
+ subject_kind: Type.Union([
73
+ Type.Literal(`principal`),
74
+ Type.Literal(`principal_kind`),
75
+ ]),
76
+ subject_value: Type.String(),
77
+ permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
78
+ expires_at: Type.Optional(Type.String()),
79
+ },
80
+ { additionalProperties: false }
81
+ )
43
82
 
44
83
  const registerEntityTypeBodySchema = Type.Object(
45
84
  {
@@ -48,8 +87,12 @@ const registerEntityTypeBodySchema = Type.Object(
48
87
  creation_schema: Type.Optional(jsonObjectSchema),
49
88
  inbox_schemas: Type.Optional(schemaMapSchema),
50
89
  state_schemas: Type.Optional(schemaMapSchema),
90
+ slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
51
91
  serve_endpoint: Type.Optional(Type.String()),
52
92
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
93
+ permission_grants: Type.Optional(
94
+ Type.Array(typePermissionGrantInputSchema)
95
+ ),
53
96
  },
54
97
  { additionalProperties: false }
55
98
  )
@@ -66,6 +109,7 @@ type RegisterEntityTypeBody = Static<typeof registerEntityTypeBodySchema>
66
109
  type AmendEntityTypeSchemasBody = Static<
67
110
  typeof amendEntityTypeSchemasBodySchema
68
111
  >
112
+ type TypePermissionGrantInput = EntityTypePermissionGrantInput
69
113
 
70
114
  export const entityTypesRouter: ElectricAgentsEntityTypeRoutes = Router<
71
115
  ElectricAgentsEntityTypeRouteRequest,
@@ -79,15 +123,47 @@ entityTypesRouter.get(`/`, listEntityTypes)
79
123
  entityTypesRouter.post(
80
124
  `/`,
81
125
  withSchema(registerEntityTypeBodySchema),
126
+ withEntityTypeRegistrationPermission,
82
127
  registerEntityType
83
128
  )
84
129
  entityTypesRouter.patch(
85
130
  `/:name/schemas`,
131
+ withExistingEntityType,
132
+ withEntityTypeManagePermission,
86
133
  withSchema(amendEntityTypeSchemasBodySchema),
87
134
  amendSchemas
88
135
  )
89
- entityTypesRouter.get(`/:name`, getEntityType)
90
- entityTypesRouter.delete(`/:name`, deleteEntityType)
136
+ entityTypesRouter.get(
137
+ `/:name`,
138
+ withExistingEntityType,
139
+ withEntityTypeSpawnPermission,
140
+ getEntityType
141
+ )
142
+ entityTypesRouter.delete(
143
+ `/:name`,
144
+ withExistingEntityType,
145
+ withEntityTypeManagePermission,
146
+ deleteEntityType
147
+ )
148
+ entityTypesRouter.get(
149
+ `/:name/grants`,
150
+ withExistingEntityType,
151
+ withEntityTypeManagePermission,
152
+ listTypePermissionGrants
153
+ )
154
+ entityTypesRouter.post(
155
+ `/:name/grants`,
156
+ withExistingEntityType,
157
+ withSchema(typePermissionGrantInputSchema),
158
+ withEntityTypeManagePermission,
159
+ createTypePermissionGrant
160
+ )
161
+ entityTypesRouter.delete(
162
+ `/:name/grants/:grantId`,
163
+ withExistingEntityType,
164
+ withEntityTypeManagePermission,
165
+ deleteTypePermissionGrant
166
+ )
91
167
 
92
168
  async function registerEntityType(
93
169
  request: ElectricAgentsEntityTypeRouteRequest,
@@ -105,6 +181,7 @@ async function registerEntityType(
105
181
  }
106
182
 
107
183
  const entityType = await ctx.entityManager.registerEntityType(normalized)
184
+ await applyRegistrationPermissionGrants(ctx, entityType.name, normalized)
108
185
  return json(toPublicEntityType(entityType), { status: 201 })
109
186
  }
110
187
 
@@ -113,7 +190,102 @@ async function listEntityTypes(
113
190
  ctx: TenantContext
114
191
  ): Promise<EntityTypeRouteResult> {
115
192
  const entityTypes = await ctx.entityManager.registry.listEntityTypes()
116
- return json(entityTypes.map((entityType) => toPublicEntityType(entityType)))
193
+ const visible: Array<ElectricAgentsEntityType> = []
194
+ for (const entityType of entityTypes) {
195
+ if (await canAccessEntityType(ctx, entityType, `spawn`)) {
196
+ visible.push(entityType)
197
+ }
198
+ }
199
+ return json(visible.map((entityType) => toPublicEntityType(entityType)))
200
+ }
201
+
202
+ async function withExistingEntityType(
203
+ request: ElectricAgentsEntityTypeRouteRequest,
204
+ ctx: TenantContext
205
+ ): Promise<EntityTypeRouteResult> {
206
+ const entityType = await ctx.entityManager.registry.getEntityType(
207
+ request.params.name
208
+ )
209
+ if (!entityType) {
210
+ return apiError(404, ErrCodeNotFound, `Entity type not found`)
211
+ }
212
+ request.entityTypeRoute = { entityType }
213
+ return undefined
214
+ }
215
+
216
+ async function withEntityTypeManagePermission(
217
+ request: ElectricAgentsEntityTypeRouteRequest,
218
+ ctx: TenantContext
219
+ ): Promise<EntityTypeRouteResult> {
220
+ const entityType = request.entityTypeRoute?.entityType
221
+ if (!entityType) {
222
+ throw new Error(`entity type middleware did not run`)
223
+ }
224
+ if (
225
+ await canAccessEntityType(ctx, entityType, `manage`, request as Request)
226
+ ) {
227
+ return undefined
228
+ }
229
+ return apiError(
230
+ 401,
231
+ ErrCodeUnauthorized,
232
+ `Principal is not allowed to manage ${entityType.name}`
233
+ )
234
+ }
235
+
236
+ async function withEntityTypeSpawnPermission(
237
+ request: ElectricAgentsEntityTypeRouteRequest,
238
+ ctx: TenantContext
239
+ ): Promise<EntityTypeRouteResult> {
240
+ const entityType = request.entityTypeRoute?.entityType
241
+ if (!entityType) {
242
+ throw new Error(`entity type middleware did not run`)
243
+ }
244
+ if (await canAccessEntityType(ctx, entityType, `spawn`, request as Request)) {
245
+ return undefined
246
+ }
247
+ return apiError(
248
+ 401,
249
+ ErrCodeUnauthorized,
250
+ `Principal is not allowed to spawn ${entityType.name}`
251
+ )
252
+ }
253
+
254
+ async function withEntityTypeRegistrationPermission(
255
+ request: ElectricAgentsEntityTypeRouteRequest,
256
+ ctx: TenantContext
257
+ ): Promise<EntityTypeRouteResult> {
258
+ const parsed = normalizeEntityTypeRequest(
259
+ routeBody<RegisterEntityTypeBody>(request)
260
+ )
261
+ if (!parsed.name) {
262
+ return undefined
263
+ }
264
+
265
+ const existing = await ctx.entityManager.registry.getEntityType(parsed.name)
266
+ if (existing) {
267
+ request.entityTypeRoute = { entityType: existing }
268
+ if (
269
+ await canAccessEntityType(ctx, existing, `manage`, request as Request)
270
+ ) {
271
+ return undefined
272
+ }
273
+ return apiError(
274
+ 401,
275
+ ErrCodeUnauthorized,
276
+ `Principal is not allowed to manage ${existing.name}`
277
+ )
278
+ }
279
+
280
+ if (await canRegisterEntityType(ctx, parsed, request as Request)) {
281
+ return undefined
282
+ }
283
+
284
+ return apiError(
285
+ 401,
286
+ ErrCodeUnauthorized,
287
+ `Principal is not allowed to register entity types`
288
+ )
117
289
  }
118
290
 
119
291
  async function discoverServeEndpoint(
@@ -141,10 +313,12 @@ async function discoverServeEndpoint(
141
313
  }
142
314
 
143
315
  manifest.serve_endpoint = parsed.serve_endpoint
316
+ manifest.permission_grants = parsed.permission_grants
144
317
 
145
318
  const entityType = await ctx.entityManager.registerEntityType(
146
319
  normalizeEntityTypeRequest(manifest)
147
320
  )
321
+ await applyRegistrationPermissionGrants(ctx, entityType.name, manifest)
148
322
  return json(toPublicEntityType(entityType), { status: 201 })
149
323
  } catch (err) {
150
324
  if (err instanceof ElectricAgentsError) {
@@ -161,17 +335,9 @@ async function discoverServeEndpoint(
161
335
  }
162
336
 
163
337
  async function getEntityType(
164
- request: ElectricAgentsEntityTypeRouteRequest,
165
- ctx: TenantContext
338
+ request: ElectricAgentsEntityTypeRouteRequest
166
339
  ): Promise<EntityTypeRouteResult> {
167
- const entityType = await ctx.entityManager.registry.getEntityType(
168
- request.params.name
169
- )
170
- if (!entityType) {
171
- return apiError(404, ErrCodeNotFound, `Entity type not found`)
172
- }
173
-
174
- return json(toPublicEntityType(entityType))
340
+ return json(toPublicEntityType(request.entityTypeRoute!.entityType))
175
341
  }
176
342
 
177
343
  async function amendSchemas(
@@ -195,6 +361,90 @@ async function deleteEntityType(
195
361
  return status(204)
196
362
  }
197
363
 
364
+ async function listTypePermissionGrants(
365
+ request: ElectricAgentsEntityTypeRouteRequest,
366
+ ctx: TenantContext
367
+ ): Promise<EntityTypeRouteResult> {
368
+ const grants =
369
+ await ctx.entityManager.registry.listEntityTypePermissionGrants(
370
+ request.entityTypeRoute!.entityType.name
371
+ )
372
+ return json({ grants })
373
+ }
374
+
375
+ async function createTypePermissionGrant(
376
+ request: ElectricAgentsEntityTypeRouteRequest,
377
+ ctx: TenantContext
378
+ ): Promise<EntityTypeRouteResult> {
379
+ const parsed = routeBody<TypePermissionGrantInput>(request)
380
+ const grant =
381
+ await ctx.entityManager.registry.createEntityTypePermissionGrant({
382
+ entityType: request.entityTypeRoute!.entityType.name,
383
+ permission: parsed.permission,
384
+ subjectKind: parsed.subject_kind,
385
+ subjectValue: parsed.subject_value,
386
+ expiresAt: parseExpiresAt(parsed.expires_at),
387
+ createdBy: ctx.principal.url,
388
+ })
389
+ return json(grant, { status: 201 })
390
+ }
391
+
392
+ async function deleteTypePermissionGrant(
393
+ request: ElectricAgentsEntityTypeRouteRequest,
394
+ ctx: TenantContext
395
+ ): Promise<EntityTypeRouteResult> {
396
+ const deleted =
397
+ await ctx.entityManager.registry.deleteEntityTypePermissionGrant(
398
+ request.entityTypeRoute!.entityType.name,
399
+ parseGrantId(request)
400
+ )
401
+ return deleted
402
+ ? status(204)
403
+ : apiError(404, ErrCodeNotFound, `Grant not found`)
404
+ }
405
+
406
+ async function applyRegistrationPermissionGrants(
407
+ ctx: TenantContext,
408
+ entityType: string,
409
+ request: Pick<RegisterEntityTypeRequest, `permission_grants`>
410
+ ): Promise<void> {
411
+ for (const grant of request.permission_grants ?? []) {
412
+ await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
413
+ entityType,
414
+ permission: grant.permission,
415
+ subjectKind: grant.subject_kind,
416
+ subjectValue: grant.subject_value,
417
+ expiresAt: parseExpiresAt(grant.expires_at),
418
+ createdBy: ctx.principal.url,
419
+ })
420
+ }
421
+ }
422
+
423
+ function parseGrantId(request: ElectricAgentsEntityTypeRouteRequest): number {
424
+ const grantId = Number.parseInt(String(request.params.grantId), 10)
425
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) {
426
+ throw new ElectricAgentsError(
427
+ ErrCodeInvalidRequest,
428
+ `Invalid grant id`,
429
+ 400
430
+ )
431
+ }
432
+ return grantId
433
+ }
434
+
435
+ function parseExpiresAt(value: string | undefined): Date | undefined {
436
+ if (value === undefined) return undefined
437
+ const expiresAt = new Date(value)
438
+ if (Number.isNaN(expiresAt.getTime())) {
439
+ throw new ElectricAgentsError(
440
+ ErrCodeInvalidRequest,
441
+ `Invalid expires_at timestamp`,
442
+ 400
443
+ )
444
+ }
445
+ return expiresAt
446
+ }
447
+
198
448
  function normalizeEntityTypeRequest(
199
449
  parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
200
450
  ): RegisterEntityTypeRequest {
@@ -205,6 +455,7 @@ function normalizeEntityTypeRequest(
205
455
  creation_schema: parsed.creation_schema,
206
456
  inbox_schemas: parsed.inbox_schemas,
207
457
  state_schemas: parsed.state_schemas,
458
+ slash_commands: parsed.slash_commands,
208
459
  serve_endpoint: serveEndpoint,
209
460
  default_dispatch_policy:
210
461
  parsed.default_dispatch_policy ??
@@ -213,6 +464,7 @@ function normalizeEntityTypeRequest(
213
464
  targets: [{ type: `webhook`, url: serveEndpoint }],
214
465
  } as RegisterEntityTypeRequest[`default_dispatch_policy`])
215
466
  : undefined),
467
+ permission_grants: parsed.permission_grants,
216
468
  }
217
469
  }
218
470
 
@@ -85,6 +85,7 @@ export function applyCors(
85
85
  `content-type`,
86
86
  `authorization`,
87
87
  `electric-claim-token`,
88
+ `electric-owner-entity`,
88
89
  ELECTRIC_PRINCIPAL_HEADER,
89
90
  `ngrok-skip-browser-warning`,
90
91
  ].join(`, `)
@@ -56,7 +56,8 @@ async function ensureEntitiesMembershipStream(
56
56
  ): Promise<Response> {
57
57
  const parsed = routeBody<EnsureEntitiesMembershipStreamBody>(request)
58
58
  const result = await ctx.entityManager.ensureEntitiesMembershipStream(
59
- parsed.tags ?? {}
59
+ parsed.tags ?? {},
60
+ ctx.principal
60
61
  )
61
62
  return json(result)
62
63
  }
package/src/runtime.ts CHANGED
@@ -316,6 +316,8 @@ export class ElectricAgentsTenantRuntime {
316
316
  payload.entityUrl,
317
317
  {
318
318
  from: payload.from,
319
+ from_principal: payload.from_principal,
320
+ from_agent: payload.from_agent,
319
321
  payload: payload.payload,
320
322
  key: payload.key ?? `scheduled-task-${taskId}`,
321
323
  type: payload.type,
@@ -461,6 +463,7 @@ export class ElectricAgentsTenantRuntime {
461
463
  {
462
464
  entityUrl: targetUrl,
463
465
  from: senderUrl,
466
+ from_agent: senderUrl,
464
467
  payload: value.payload,
465
468
  key: `scheduled-${producerId}`,
466
469
  type:
@@ -499,6 +502,14 @@ export class ElectricAgentsTenantRuntime {
499
502
  manifestKey,
500
503
  sourceRef
501
504
  )
505
+
506
+ const sharedStateId =
507
+ operation === `delete` ? undefined : this.extractSharedStateId(value)
508
+ await this.manager.registry.replaceSharedStateLink(
509
+ ownerEntityUrl,
510
+ manifestKey,
511
+ sharedStateId
512
+ )
502
513
  }
503
514
 
504
515
  private extractEntitiesSourceRef(
@@ -514,6 +525,29 @@ export class ElectricAgentsTenantRuntime {
514
525
  return undefined
515
526
  }
516
527
 
528
+ private extractSharedStateId(
529
+ manifest: Record<string, unknown> | undefined
530
+ ): string | undefined {
531
+ if (manifest?.kind === `shared-state` && typeof manifest.id === `string`) {
532
+ return manifest.id
533
+ }
534
+
535
+ if (manifest?.kind !== `source` || manifest.sourceType !== `db`) {
536
+ return undefined
537
+ }
538
+
539
+ if (typeof manifest.sourceRef === `string`) {
540
+ return manifest.sourceRef
541
+ }
542
+ const config =
543
+ typeof manifest.config === `object` &&
544
+ manifest.config !== null &&
545
+ !Array.isArray(manifest.config)
546
+ ? (manifest.config as Record<string, unknown>)
547
+ : undefined
548
+ return typeof config?.id === `string` ? config.id : undefined
549
+ }
550
+
517
551
  private async maybeMarkEntityIdleAfterRunFinished(
518
552
  entityUrl: string
519
553
  ): Promise<void> {
package/src/scheduler.ts CHANGED
@@ -6,6 +6,8 @@ import type { PgClient } from './db/index.js'
6
6
  export interface DelayedSendPayload {
7
7
  entityUrl: string
8
8
  from?: string
9
+ from_principal?: string
10
+ from_agent?: string
9
11
  payload: unknown
10
12
  key?: string
11
13
  type?: string
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import { apiError } from './electric-agents-http.js'
16
16
  import {
17
17
  ErrCodeInvalidRequest,
18
18
  ErrCodeUnauthorized,
19
+ type AuthorizeRequest,
19
20
  } from './electric-agents-types.js'
20
21
  import { ElectricAgentsError } from './entity-manager.js'
21
22
  import { serverLog } from './utils/log.js'
@@ -67,6 +68,7 @@ export interface ElectricAgentsServerOptions {
67
68
  authenticateRequest?: (
68
69
  request: Request
69
70
  ) => Promise<Principal | null> | Principal | null
71
+ authorizeRequest?: AuthorizeRequest
70
72
  allowDevPrincipalFallback?: boolean
71
73
  eventSources?: EventSourceCatalog
72
74
  ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
@@ -453,6 +455,9 @@ export class ElectricAgentsServer {
453
455
  this.options.ensureEventSourceWakeSource,
454
456
  }
455
457
  : {}),
458
+ ...(this.options.authorizeRequest
459
+ ? { authorizeRequest: this.options.authorizeRequest }
460
+ : {}),
456
461
  isShuttingDown: () => this.shuttingDown,
457
462
  mockAgent: this.mockAgentBootstrap
458
463
  ? { runtime: this.mockAgentBootstrap.runtime }