@electric-ax/agents-server 0.4.15 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -41,6 +46,19 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
41
46
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
42
47
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
43
48
 
49
+ const typePermissionGrantInputSchema = Type.Object(
50
+ {
51
+ subject_kind: Type.Union([
52
+ Type.Literal(`principal`),
53
+ Type.Literal(`principal_kind`),
54
+ ]),
55
+ subject_value: Type.String(),
56
+ permission: Type.Union([Type.Literal(`spawn`), Type.Literal(`manage`)]),
57
+ expires_at: Type.Optional(Type.String()),
58
+ },
59
+ { additionalProperties: false }
60
+ )
61
+
44
62
  const registerEntityTypeBodySchema = Type.Object(
45
63
  {
46
64
  name: Type.Optional(Type.String()),
@@ -50,6 +68,9 @@ const registerEntityTypeBodySchema = Type.Object(
50
68
  state_schemas: Type.Optional(schemaMapSchema),
51
69
  serve_endpoint: Type.Optional(Type.String()),
52
70
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
71
+ permission_grants: Type.Optional(
72
+ Type.Array(typePermissionGrantInputSchema)
73
+ ),
53
74
  },
54
75
  { additionalProperties: false }
55
76
  )
@@ -66,6 +87,7 @@ type RegisterEntityTypeBody = Static<typeof registerEntityTypeBodySchema>
66
87
  type AmendEntityTypeSchemasBody = Static<
67
88
  typeof amendEntityTypeSchemasBodySchema
68
89
  >
90
+ type TypePermissionGrantInput = EntityTypePermissionGrantInput
69
91
 
70
92
  export const entityTypesRouter: ElectricAgentsEntityTypeRoutes = Router<
71
93
  ElectricAgentsEntityTypeRouteRequest,
@@ -79,15 +101,47 @@ entityTypesRouter.get(`/`, listEntityTypes)
79
101
  entityTypesRouter.post(
80
102
  `/`,
81
103
  withSchema(registerEntityTypeBodySchema),
104
+ withEntityTypeRegistrationPermission,
82
105
  registerEntityType
83
106
  )
84
107
  entityTypesRouter.patch(
85
108
  `/:name/schemas`,
109
+ withExistingEntityType,
110
+ withEntityTypeManagePermission,
86
111
  withSchema(amendEntityTypeSchemasBodySchema),
87
112
  amendSchemas
88
113
  )
89
- entityTypesRouter.get(`/:name`, getEntityType)
90
- entityTypesRouter.delete(`/:name`, deleteEntityType)
114
+ entityTypesRouter.get(
115
+ `/:name`,
116
+ withExistingEntityType,
117
+ withEntityTypeSpawnPermission,
118
+ getEntityType
119
+ )
120
+ entityTypesRouter.delete(
121
+ `/:name`,
122
+ withExistingEntityType,
123
+ withEntityTypeManagePermission,
124
+ deleteEntityType
125
+ )
126
+ entityTypesRouter.get(
127
+ `/:name/grants`,
128
+ withExistingEntityType,
129
+ withEntityTypeManagePermission,
130
+ listTypePermissionGrants
131
+ )
132
+ entityTypesRouter.post(
133
+ `/:name/grants`,
134
+ withExistingEntityType,
135
+ withSchema(typePermissionGrantInputSchema),
136
+ withEntityTypeManagePermission,
137
+ createTypePermissionGrant
138
+ )
139
+ entityTypesRouter.delete(
140
+ `/:name/grants/:grantId`,
141
+ withExistingEntityType,
142
+ withEntityTypeManagePermission,
143
+ deleteTypePermissionGrant
144
+ )
91
145
 
92
146
  async function registerEntityType(
93
147
  request: ElectricAgentsEntityTypeRouteRequest,
@@ -105,6 +159,7 @@ async function registerEntityType(
105
159
  }
106
160
 
107
161
  const entityType = await ctx.entityManager.registerEntityType(normalized)
162
+ await applyRegistrationPermissionGrants(ctx, entityType.name, normalized)
108
163
  return json(toPublicEntityType(entityType), { status: 201 })
109
164
  }
110
165
 
@@ -113,7 +168,102 @@ async function listEntityTypes(
113
168
  ctx: TenantContext
114
169
  ): Promise<EntityTypeRouteResult> {
115
170
  const entityTypes = await ctx.entityManager.registry.listEntityTypes()
116
- return json(entityTypes.map((entityType) => toPublicEntityType(entityType)))
171
+ const visible: Array<ElectricAgentsEntityType> = []
172
+ for (const entityType of entityTypes) {
173
+ if (await canAccessEntityType(ctx, entityType, `spawn`)) {
174
+ visible.push(entityType)
175
+ }
176
+ }
177
+ return json(visible.map((entityType) => toPublicEntityType(entityType)))
178
+ }
179
+
180
+ async function withExistingEntityType(
181
+ request: ElectricAgentsEntityTypeRouteRequest,
182
+ ctx: TenantContext
183
+ ): Promise<EntityTypeRouteResult> {
184
+ const entityType = await ctx.entityManager.registry.getEntityType(
185
+ request.params.name
186
+ )
187
+ if (!entityType) {
188
+ return apiError(404, ErrCodeNotFound, `Entity type not found`)
189
+ }
190
+ request.entityTypeRoute = { entityType }
191
+ return undefined
192
+ }
193
+
194
+ async function withEntityTypeManagePermission(
195
+ request: ElectricAgentsEntityTypeRouteRequest,
196
+ ctx: TenantContext
197
+ ): Promise<EntityTypeRouteResult> {
198
+ const entityType = request.entityTypeRoute?.entityType
199
+ if (!entityType) {
200
+ throw new Error(`entity type middleware did not run`)
201
+ }
202
+ if (
203
+ await canAccessEntityType(ctx, entityType, `manage`, request as Request)
204
+ ) {
205
+ return undefined
206
+ }
207
+ return apiError(
208
+ 401,
209
+ ErrCodeUnauthorized,
210
+ `Principal is not allowed to manage ${entityType.name}`
211
+ )
212
+ }
213
+
214
+ async function withEntityTypeSpawnPermission(
215
+ request: ElectricAgentsEntityTypeRouteRequest,
216
+ ctx: TenantContext
217
+ ): Promise<EntityTypeRouteResult> {
218
+ const entityType = request.entityTypeRoute?.entityType
219
+ if (!entityType) {
220
+ throw new Error(`entity type middleware did not run`)
221
+ }
222
+ if (await canAccessEntityType(ctx, entityType, `spawn`, request as Request)) {
223
+ return undefined
224
+ }
225
+ return apiError(
226
+ 401,
227
+ ErrCodeUnauthorized,
228
+ `Principal is not allowed to spawn ${entityType.name}`
229
+ )
230
+ }
231
+
232
+ async function withEntityTypeRegistrationPermission(
233
+ request: ElectricAgentsEntityTypeRouteRequest,
234
+ ctx: TenantContext
235
+ ): Promise<EntityTypeRouteResult> {
236
+ const parsed = normalizeEntityTypeRequest(
237
+ routeBody<RegisterEntityTypeBody>(request)
238
+ )
239
+ if (!parsed.name) {
240
+ return undefined
241
+ }
242
+
243
+ const existing = await ctx.entityManager.registry.getEntityType(parsed.name)
244
+ if (existing) {
245
+ request.entityTypeRoute = { entityType: existing }
246
+ if (
247
+ await canAccessEntityType(ctx, existing, `manage`, request as Request)
248
+ ) {
249
+ return undefined
250
+ }
251
+ return apiError(
252
+ 401,
253
+ ErrCodeUnauthorized,
254
+ `Principal is not allowed to manage ${existing.name}`
255
+ )
256
+ }
257
+
258
+ if (await canRegisterEntityType(ctx, parsed, request as Request)) {
259
+ return undefined
260
+ }
261
+
262
+ return apiError(
263
+ 401,
264
+ ErrCodeUnauthorized,
265
+ `Principal is not allowed to register entity types`
266
+ )
117
267
  }
118
268
 
119
269
  async function discoverServeEndpoint(
@@ -141,10 +291,12 @@ async function discoverServeEndpoint(
141
291
  }
142
292
 
143
293
  manifest.serve_endpoint = parsed.serve_endpoint
294
+ manifest.permission_grants = parsed.permission_grants
144
295
 
145
296
  const entityType = await ctx.entityManager.registerEntityType(
146
297
  normalizeEntityTypeRequest(manifest)
147
298
  )
299
+ await applyRegistrationPermissionGrants(ctx, entityType.name, manifest)
148
300
  return json(toPublicEntityType(entityType), { status: 201 })
149
301
  } catch (err) {
150
302
  if (err instanceof ElectricAgentsError) {
@@ -161,17 +313,9 @@ async function discoverServeEndpoint(
161
313
  }
162
314
 
163
315
  async function getEntityType(
164
- request: ElectricAgentsEntityTypeRouteRequest,
165
- ctx: TenantContext
316
+ request: ElectricAgentsEntityTypeRouteRequest
166
317
  ): 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))
318
+ return json(toPublicEntityType(request.entityTypeRoute!.entityType))
175
319
  }
176
320
 
177
321
  async function amendSchemas(
@@ -195,6 +339,90 @@ async function deleteEntityType(
195
339
  return status(204)
196
340
  }
197
341
 
342
+ async function listTypePermissionGrants(
343
+ request: ElectricAgentsEntityTypeRouteRequest,
344
+ ctx: TenantContext
345
+ ): Promise<EntityTypeRouteResult> {
346
+ const grants =
347
+ await ctx.entityManager.registry.listEntityTypePermissionGrants(
348
+ request.entityTypeRoute!.entityType.name
349
+ )
350
+ return json({ grants })
351
+ }
352
+
353
+ async function createTypePermissionGrant(
354
+ request: ElectricAgentsEntityTypeRouteRequest,
355
+ ctx: TenantContext
356
+ ): Promise<EntityTypeRouteResult> {
357
+ const parsed = routeBody<TypePermissionGrantInput>(request)
358
+ const grant =
359
+ await ctx.entityManager.registry.createEntityTypePermissionGrant({
360
+ entityType: request.entityTypeRoute!.entityType.name,
361
+ permission: parsed.permission,
362
+ subjectKind: parsed.subject_kind,
363
+ subjectValue: parsed.subject_value,
364
+ expiresAt: parseExpiresAt(parsed.expires_at),
365
+ createdBy: ctx.principal.url,
366
+ })
367
+ return json(grant, { status: 201 })
368
+ }
369
+
370
+ async function deleteTypePermissionGrant(
371
+ request: ElectricAgentsEntityTypeRouteRequest,
372
+ ctx: TenantContext
373
+ ): Promise<EntityTypeRouteResult> {
374
+ const deleted =
375
+ await ctx.entityManager.registry.deleteEntityTypePermissionGrant(
376
+ request.entityTypeRoute!.entityType.name,
377
+ parseGrantId(request)
378
+ )
379
+ return deleted
380
+ ? status(204)
381
+ : apiError(404, ErrCodeNotFound, `Grant not found`)
382
+ }
383
+
384
+ async function applyRegistrationPermissionGrants(
385
+ ctx: TenantContext,
386
+ entityType: string,
387
+ request: Pick<RegisterEntityTypeRequest, `permission_grants`>
388
+ ): Promise<void> {
389
+ for (const grant of request.permission_grants ?? []) {
390
+ await ctx.entityManager.registry.ensureEntityTypePermissionGrant({
391
+ entityType,
392
+ permission: grant.permission,
393
+ subjectKind: grant.subject_kind,
394
+ subjectValue: grant.subject_value,
395
+ expiresAt: parseExpiresAt(grant.expires_at),
396
+ createdBy: ctx.principal.url,
397
+ })
398
+ }
399
+ }
400
+
401
+ function parseGrantId(request: ElectricAgentsEntityTypeRouteRequest): number {
402
+ const grantId = Number.parseInt(String(request.params.grantId), 10)
403
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) {
404
+ throw new ElectricAgentsError(
405
+ ErrCodeInvalidRequest,
406
+ `Invalid grant id`,
407
+ 400
408
+ )
409
+ }
410
+ return grantId
411
+ }
412
+
413
+ function parseExpiresAt(value: string | undefined): Date | undefined {
414
+ if (value === undefined) return undefined
415
+ const expiresAt = new Date(value)
416
+ if (Number.isNaN(expiresAt.getTime())) {
417
+ throw new ElectricAgentsError(
418
+ ErrCodeInvalidRequest,
419
+ `Invalid expires_at timestamp`,
420
+ 400
421
+ )
422
+ }
423
+ return expiresAt
424
+ }
425
+
198
426
  function normalizeEntityTypeRequest(
199
427
  parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
200
428
  ): RegisterEntityTypeRequest {
@@ -213,6 +441,7 @@ function normalizeEntityTypeRequest(
213
441
  targets: [{ type: `webhook`, url: serveEndpoint }],
214
442
  } as RegisterEntityTypeRequest[`default_dispatch_policy`])
215
443
  : undefined),
444
+ permission_grants: parsed.permission_grants,
216
445
  }
217
446
  }
218
447
 
@@ -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 }