@electric-ax/agents-server 0.4.15 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +1176 -232
- package/dist/index.cjs +1179 -226
- package/dist/index.d.cts +1146 -167
- package/dist/index.d.ts +1146 -167
- package/dist/index.js +1181 -228
- 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 +21 -0
- package/package.json +7 -7
- package/src/db/schema.ts +198 -0
- package/src/electric-agents-types.ts +76 -2
- package/src/entity-bridge-manager.ts +57 -6
- package/src/entity-manager.ts +78 -60
- package/src/entity-projector.ts +76 -17
- package/src/entity-registry.ts +608 -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 +344 -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/runtime.ts +34 -0
- package/src/scheduler.ts +2 -0
- package/src/server.ts +5 -0
- package/src/utils/server-utils.ts +191 -11
- package/src/wake-registry.ts +8 -0
package/src/index.ts
CHANGED
|
@@ -43,6 +43,12 @@ export type {
|
|
|
43
43
|
ElectricAgentsEntity,
|
|
44
44
|
ElectricAgentsEntityRow,
|
|
45
45
|
ElectricAgentsEntityType,
|
|
46
|
+
EntityPermission,
|
|
47
|
+
EntityPermissionGrant,
|
|
48
|
+
EntityPermissionPropagation,
|
|
49
|
+
EntityTypePermission,
|
|
50
|
+
EntityTypePermissionGrant,
|
|
51
|
+
EntityTypePermissionGrantInput,
|
|
46
52
|
EntityStatus,
|
|
47
53
|
EntitySignal,
|
|
48
54
|
PublicElectricAgentsEntity,
|
|
@@ -52,6 +58,11 @@ export type {
|
|
|
52
58
|
SignalRequest,
|
|
53
59
|
SignalResponse,
|
|
54
60
|
TypedSpawnRequest,
|
|
61
|
+
PermissionSubject,
|
|
62
|
+
PermissionSubjectKind,
|
|
63
|
+
AuthorizationDecision,
|
|
64
|
+
AuthorizationResource,
|
|
65
|
+
AuthorizeRequest,
|
|
55
66
|
} from './electric-agents-types.js'
|
|
56
67
|
export type {
|
|
57
68
|
EventSourceBucket,
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { isBuiltInSystemPrincipalUrl } from './principal.js'
|
|
2
|
+
import type {
|
|
3
|
+
AuthorizeRequest,
|
|
4
|
+
ElectricAgentsEntity,
|
|
5
|
+
ElectricAgentsEntityType,
|
|
6
|
+
EntityPermission,
|
|
7
|
+
RegisterEntityTypeRequest,
|
|
8
|
+
EntityTypePermission,
|
|
9
|
+
} from './electric-agents-types.js'
|
|
10
|
+
import type { TenantContext } from './routing/context.js'
|
|
11
|
+
import { serverLog } from './utils/log.js'
|
|
12
|
+
|
|
13
|
+
const authzDecisionCache = new WeakMap<
|
|
14
|
+
AuthorizeRequest,
|
|
15
|
+
Map<string, { decision: `allow` | `deny`; expiresAt: number }>
|
|
16
|
+
>()
|
|
17
|
+
|
|
18
|
+
export function principalSubject(principal: { url: string; kind: string }): {
|
|
19
|
+
principalUrl: string
|
|
20
|
+
principalKind: string
|
|
21
|
+
} {
|
|
22
|
+
return { principalUrl: principal.url, principalKind: principal.kind }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isPermissionBypassPrincipal(ctx: TenantContext): boolean {
|
|
26
|
+
return isBuiltInSystemPrincipalUrl(ctx.principal.url)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function canAccessEntity(
|
|
30
|
+
ctx: TenantContext,
|
|
31
|
+
entity: ElectricAgentsEntity,
|
|
32
|
+
permission: EntityPermission,
|
|
33
|
+
request?: Request
|
|
34
|
+
): Promise<boolean> {
|
|
35
|
+
if (isPermissionBypassPrincipal(ctx)) return true
|
|
36
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
|
|
37
|
+
|
|
38
|
+
const builtInAllowed =
|
|
39
|
+
entity.created_by === ctx.principal.url ||
|
|
40
|
+
(await ctx.entityManager.registry.hasEntityPermission(
|
|
41
|
+
entity.url,
|
|
42
|
+
permission,
|
|
43
|
+
principalSubject(ctx.principal)
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
return await applyAuthorizationHook(ctx, {
|
|
47
|
+
verb: permission,
|
|
48
|
+
resourceKey: `entity:${entity.url}`,
|
|
49
|
+
resource: { kind: `entity`, entity },
|
|
50
|
+
builtInAllowed,
|
|
51
|
+
request,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function canAccessEntityType(
|
|
56
|
+
ctx: TenantContext,
|
|
57
|
+
entityType: ElectricAgentsEntityType,
|
|
58
|
+
permission: EntityTypePermission,
|
|
59
|
+
request?: Request
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
if (isPermissionBypassPrincipal(ctx)) return true
|
|
62
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
|
|
63
|
+
|
|
64
|
+
const builtInAllowed =
|
|
65
|
+
await ctx.entityManager.registry.hasEntityTypePermission(
|
|
66
|
+
entityType.name,
|
|
67
|
+
permission,
|
|
68
|
+
principalSubject(ctx.principal)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return await applyAuthorizationHook(ctx, {
|
|
72
|
+
verb: permission,
|
|
73
|
+
resourceKey: `entity_type:${entityType.name}`,
|
|
74
|
+
resource: { kind: `entity_type`, entityType },
|
|
75
|
+
builtInAllowed,
|
|
76
|
+
request,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function canRegisterEntityType(
|
|
81
|
+
ctx: TenantContext,
|
|
82
|
+
input: Pick<RegisterEntityTypeRequest, `name`>,
|
|
83
|
+
request?: Request
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
if (isPermissionBypassPrincipal(ctx)) return true
|
|
86
|
+
|
|
87
|
+
return await applyAuthorizationHook(ctx, {
|
|
88
|
+
verb: `manage`,
|
|
89
|
+
resourceKey: `entity_type_registration:${input.name}`,
|
|
90
|
+
resource: {
|
|
91
|
+
kind: `entity_type_registration`,
|
|
92
|
+
entityTypeName: input.name,
|
|
93
|
+
},
|
|
94
|
+
builtInAllowed: true,
|
|
95
|
+
request,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function canAccessSharedState(
|
|
100
|
+
ctx: TenantContext,
|
|
101
|
+
sharedStateId: string,
|
|
102
|
+
permission: `read` | `write`,
|
|
103
|
+
request?: Request,
|
|
104
|
+
ownerEntityUrl?: string
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
if (isPermissionBypassPrincipal(ctx)) return true
|
|
107
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
|
|
108
|
+
|
|
109
|
+
const storedLinkedEntityUrls =
|
|
110
|
+
await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(
|
|
111
|
+
sharedStateId
|
|
112
|
+
)
|
|
113
|
+
const bootstrapEntityUrls =
|
|
114
|
+
storedLinkedEntityUrls.length === 0 && ownerEntityUrl
|
|
115
|
+
? [ownerEntityUrl]
|
|
116
|
+
: []
|
|
117
|
+
const linkedEntityUrls = [
|
|
118
|
+
...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls]),
|
|
119
|
+
]
|
|
120
|
+
for (const entityUrl of linkedEntityUrls) {
|
|
121
|
+
const entity = await ctx.entityManager.registry.getEntity(entityUrl)
|
|
122
|
+
if (!entity) continue
|
|
123
|
+
if (
|
|
124
|
+
entity.created_by === ctx.principal.url ||
|
|
125
|
+
(await ctx.entityManager.registry.hasEntityPermission(
|
|
126
|
+
entity.url,
|
|
127
|
+
permission,
|
|
128
|
+
principalSubject(ctx.principal)
|
|
129
|
+
))
|
|
130
|
+
) {
|
|
131
|
+
return await applyAuthorizationHook(ctx, {
|
|
132
|
+
verb: permission,
|
|
133
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
134
|
+
resource: {
|
|
135
|
+
kind: `shared_state`,
|
|
136
|
+
sharedStateId,
|
|
137
|
+
linkedEntityUrls,
|
|
138
|
+
},
|
|
139
|
+
builtInAllowed: true,
|
|
140
|
+
request,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return await applyAuthorizationHook(ctx, {
|
|
146
|
+
verb: permission,
|
|
147
|
+
resourceKey: `shared_state:${sharedStateId}`,
|
|
148
|
+
resource: {
|
|
149
|
+
kind: `shared_state`,
|
|
150
|
+
sharedStateId,
|
|
151
|
+
linkedEntityUrls,
|
|
152
|
+
},
|
|
153
|
+
builtInAllowed: false,
|
|
154
|
+
request,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function applyAuthorizationHook(
|
|
159
|
+
ctx: TenantContext,
|
|
160
|
+
input: {
|
|
161
|
+
verb: EntityPermission | EntityTypePermission
|
|
162
|
+
resourceKey: string
|
|
163
|
+
resource: Parameters<AuthorizeRequest>[0][`resource`]
|
|
164
|
+
builtInAllowed: boolean
|
|
165
|
+
request?: Request
|
|
166
|
+
}
|
|
167
|
+
): Promise<boolean> {
|
|
168
|
+
const hook = ctx.authorizeRequest
|
|
169
|
+
if (!hook) return input.builtInAllowed
|
|
170
|
+
|
|
171
|
+
const cacheKey = [
|
|
172
|
+
ctx.service,
|
|
173
|
+
ctx.principal.url,
|
|
174
|
+
input.verb,
|
|
175
|
+
input.resourceKey,
|
|
176
|
+
].join(`|`)
|
|
177
|
+
const cached = getCachedDecision(hook, cacheKey)
|
|
178
|
+
if (cached) return cached.decision === `allow`
|
|
179
|
+
|
|
180
|
+
let decision: Awaited<ReturnType<AuthorizeRequest>>
|
|
181
|
+
try {
|
|
182
|
+
decision = await hook({
|
|
183
|
+
tenant: ctx.service,
|
|
184
|
+
principal: ctx.principal,
|
|
185
|
+
verb: input.verb,
|
|
186
|
+
resource: input.resource,
|
|
187
|
+
request: input.request ? requestMetadata(input.request) : undefined,
|
|
188
|
+
builtInAllowed: input.builtInAllowed,
|
|
189
|
+
})
|
|
190
|
+
} catch (error) {
|
|
191
|
+
serverLog.warn(`[agent-server] authorization hook failed:`, error)
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
cacheDecision(hook, cacheKey, decision)
|
|
196
|
+
return decision.decision === `allow`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getCachedDecision(
|
|
200
|
+
hook: AuthorizeRequest,
|
|
201
|
+
cacheKey: string
|
|
202
|
+
): { decision: `allow` | `deny` } | null {
|
|
203
|
+
const cache = authzDecisionCache.get(hook)
|
|
204
|
+
const entry = cache?.get(cacheKey)
|
|
205
|
+
if (!entry) return null
|
|
206
|
+
if (entry.expiresAt <= Date.now()) {
|
|
207
|
+
cache?.delete(cacheKey)
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
return { decision: entry.decision }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cacheDecision(
|
|
214
|
+
hook: AuthorizeRequest,
|
|
215
|
+
cacheKey: string,
|
|
216
|
+
decision: Awaited<ReturnType<AuthorizeRequest>>
|
|
217
|
+
): void {
|
|
218
|
+
if (!decision.expires_at) return
|
|
219
|
+
const expiresAt = Date.parse(decision.expires_at)
|
|
220
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return
|
|
221
|
+
let cache = authzDecisionCache.get(hook)
|
|
222
|
+
if (!cache) {
|
|
223
|
+
cache = new Map()
|
|
224
|
+
authzDecisionCache.set(hook, cache)
|
|
225
|
+
}
|
|
226
|
+
cache.set(cacheKey, { decision: decision.decision, expiresAt })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function requestMetadata(request: Request): {
|
|
230
|
+
method: string
|
|
231
|
+
url: string
|
|
232
|
+
headers: Record<string, string>
|
|
233
|
+
} {
|
|
234
|
+
const headers: Record<string, string> = {}
|
|
235
|
+
request.headers.forEach((value, key) => {
|
|
236
|
+
headers[key] = value
|
|
237
|
+
})
|
|
238
|
+
return { method: request.method, url: request.url, headers }
|
|
239
|
+
}
|
package/src/routing/context.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-ada
|
|
|
12
12
|
import type { Principal } from '../principal.js'
|
|
13
13
|
import type { DurableStreamsBearerProvider } from '../stream-client.js'
|
|
14
14
|
import type { WebhookSigner } from '../webhook-signing.js'
|
|
15
|
+
import type { AuthorizeRequest } from '../electric-agents-types.js'
|
|
15
16
|
|
|
16
17
|
export interface EventSourceCatalog {
|
|
17
18
|
listEventSources: () =>
|
|
@@ -55,5 +56,6 @@ export interface TenantContext {
|
|
|
55
56
|
entityBridgeManager: EntityBridgeCoordinator
|
|
56
57
|
eventSources?: EventSourceCatalog
|
|
57
58
|
ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
59
|
+
authorizeRequest?: AuthorizeRequest
|
|
58
60
|
isShuttingDown: () => boolean
|
|
59
61
|
}
|
|
@@ -6,8 +6,16 @@ import { appendPathToUrl } from '@electric-ax/agents-runtime'
|
|
|
6
6
|
import { Type, type Static } from '@sinclair/typebox'
|
|
7
7
|
import { and, eq } from 'drizzle-orm'
|
|
8
8
|
import { Router } from 'itty-router'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
apiError,
|
|
11
|
+
readRequestBody,
|
|
12
|
+
responseHeaders,
|
|
13
|
+
} from '../electric-agents-http.js'
|
|
10
14
|
import { subscriptionWebhooks } from '../db/schema.js'
|
|
15
|
+
import {
|
|
16
|
+
ErrCodeNotFound,
|
|
17
|
+
ErrCodeUnauthorized,
|
|
18
|
+
} from '../electric-agents-types.js'
|
|
11
19
|
import {
|
|
12
20
|
createStreamAppendRouteRequest,
|
|
13
21
|
electricAgentsStreamAppendRouter,
|
|
@@ -15,6 +23,7 @@ import {
|
|
|
15
23
|
import { validateBody } from './schema.js'
|
|
16
24
|
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
17
25
|
import { forwardFetchRequest } from '../utils/server-utils.js'
|
|
26
|
+
import { canAccessEntity, canAccessSharedState } from '../permissions.js'
|
|
18
27
|
import {
|
|
19
28
|
getDefaultWebhookSigner,
|
|
20
29
|
webhookSigningMetadata,
|
|
@@ -48,6 +57,8 @@ const subscriptionControlActions = [
|
|
|
48
57
|
`release`,
|
|
49
58
|
] as const
|
|
50
59
|
|
|
60
|
+
const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`
|
|
61
|
+
|
|
51
62
|
export type DurableStreamsRoutes = RouterType<
|
|
52
63
|
IRequest,
|
|
53
64
|
[TenantContext],
|
|
@@ -583,6 +594,8 @@ async function streamAppend(
|
|
|
583
594
|
request: IRequest,
|
|
584
595
|
ctx: TenantContext
|
|
585
596
|
): Promise<Response | undefined> {
|
|
597
|
+
const auth = await authorizeDurableStreamAccess(request, ctx)
|
|
598
|
+
if (auth) return auth
|
|
586
599
|
return await electricAgentsStreamAppendRouter.fetch(
|
|
587
600
|
createStreamAppendRouteRequest(request as Request),
|
|
588
601
|
ctx.runtime,
|
|
@@ -608,10 +621,9 @@ async function proxyPassThrough(
|
|
|
608
621
|
request: IRequest,
|
|
609
622
|
ctx: TenantContext
|
|
610
623
|
): Promise<Response> {
|
|
624
|
+
const auth = await authorizeDurableStreamAccess(request, ctx)
|
|
625
|
+
if (auth) return auth
|
|
611
626
|
const streamPath = new URL(request.url).pathname
|
|
612
|
-
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) {
|
|
613
|
-
return new Response(null, { status: 404 })
|
|
614
|
-
}
|
|
615
627
|
const upstream = await forwardToDurableStreams(ctx, request)
|
|
616
628
|
const method = request.method.toUpperCase()
|
|
617
629
|
const endTrackedRead =
|
|
@@ -627,3 +639,112 @@ async function proxyPassThrough(
|
|
|
627
639
|
await endTrackedRead?.()
|
|
628
640
|
}
|
|
629
641
|
}
|
|
642
|
+
|
|
643
|
+
async function authorizeDurableStreamAccess(
|
|
644
|
+
request: IRequest,
|
|
645
|
+
ctx: TenantContext
|
|
646
|
+
): Promise<Response | undefined> {
|
|
647
|
+
const method = request.method.toUpperCase()
|
|
648
|
+
const streamPath = new URL(request.url).pathname
|
|
649
|
+
|
|
650
|
+
if (method === `GET` || method === `HEAD`) {
|
|
651
|
+
const registry = ctx.entityManager?.registry
|
|
652
|
+
const entity = registry?.getEntityByStream
|
|
653
|
+
? await registry.getEntityByStream(streamPath)
|
|
654
|
+
: null
|
|
655
|
+
if (entity) {
|
|
656
|
+
if (await canAccessEntity(ctx, entity, `read`, request as Request)) {
|
|
657
|
+
return undefined
|
|
658
|
+
}
|
|
659
|
+
return apiError(
|
|
660
|
+
401,
|
|
661
|
+
ErrCodeUnauthorized,
|
|
662
|
+
`Principal is not allowed to read ${entity.url}`
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath)
|
|
667
|
+
if (attachmentEntityUrl) {
|
|
668
|
+
const attachmentEntity = registry?.getEntity
|
|
669
|
+
? await registry.getEntity(attachmentEntityUrl)
|
|
670
|
+
: null
|
|
671
|
+
if (!attachmentEntity) {
|
|
672
|
+
return apiError(404, ErrCodeNotFound, `Entity not found`)
|
|
673
|
+
}
|
|
674
|
+
if (
|
|
675
|
+
await canAccessEntity(ctx, attachmentEntity, `read`, request as Request)
|
|
676
|
+
) {
|
|
677
|
+
return undefined
|
|
678
|
+
}
|
|
679
|
+
return apiError(
|
|
680
|
+
401,
|
|
681
|
+
ErrCodeUnauthorized,
|
|
682
|
+
`Principal is not allowed to read ${attachmentEntity.url}`
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const sharedStateId = sharedStateIdFromPath(streamPath)
|
|
688
|
+
if (!sharedStateId) {
|
|
689
|
+
// Durable Streams also hosts non-Agents utility streams. Entity streams,
|
|
690
|
+
// attachment streams, and shared-state streams are guarded above; paths that
|
|
691
|
+
// do not match those resource classes are intentionally passed through.
|
|
692
|
+
return undefined
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (method === `GET` || method === `HEAD`) {
|
|
696
|
+
if (
|
|
697
|
+
await canAccessSharedState(ctx, sharedStateId, `read`, request as Request)
|
|
698
|
+
) {
|
|
699
|
+
return undefined
|
|
700
|
+
}
|
|
701
|
+
return apiError(
|
|
702
|
+
401,
|
|
703
|
+
ErrCodeUnauthorized,
|
|
704
|
+
`Principal is not allowed to read shared state`
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (method === `PUT` || method === `POST`) {
|
|
709
|
+
const ownerEntityUrl =
|
|
710
|
+
request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || undefined
|
|
711
|
+
if (
|
|
712
|
+
await canAccessSharedState(
|
|
713
|
+
ctx,
|
|
714
|
+
sharedStateId,
|
|
715
|
+
`write`,
|
|
716
|
+
request as Request,
|
|
717
|
+
ownerEntityUrl
|
|
718
|
+
)
|
|
719
|
+
) {
|
|
720
|
+
return undefined
|
|
721
|
+
}
|
|
722
|
+
return apiError(
|
|
723
|
+
401,
|
|
724
|
+
ErrCodeUnauthorized,
|
|
725
|
+
`Principal is not allowed to write shared state`
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return apiError(
|
|
730
|
+
401,
|
|
731
|
+
ErrCodeUnauthorized,
|
|
732
|
+
`Principal is not allowed to access shared state`
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function entityUrlFromAttachmentStreamPath(path: string): string | null {
|
|
737
|
+
const match = path.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/)
|
|
738
|
+
if (!match) return null
|
|
739
|
+
return `/${match[1]}/${match[2]}`
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function sharedStateIdFromPath(path: string): string | null {
|
|
743
|
+
const match = path.match(/^\/_electric\/shared-state\/([^/]+)$/)
|
|
744
|
+
if (!match) return null
|
|
745
|
+
try {
|
|
746
|
+
return decodeURIComponent(match[1]!)
|
|
747
|
+
} catch {
|
|
748
|
+
return match[1]!
|
|
749
|
+
}
|
|
750
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { Router } from 'itty-router'
|
|
7
7
|
import { apiError, responseHeaders } from '../electric-agents-http.js'
|
|
8
|
+
import { isPermissionBypassPrincipal } from '../permissions.js'
|
|
8
9
|
import { buildElectricProxyTarget } from '../utils/server-utils.js'
|
|
9
10
|
import type { IRequest, RouterType } from 'itty-router'
|
|
10
11
|
import type { TenantContext } from './context.js'
|
|
@@ -33,12 +34,15 @@ async function proxyElectric(
|
|
|
33
34
|
return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`)
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
|
|
36
38
|
const target = buildElectricProxyTarget({
|
|
37
39
|
incomingUrl: new URL(request.url),
|
|
38
40
|
electricUrl: ctx.electricUrl,
|
|
39
41
|
electricSecret: ctx.electricSecret,
|
|
40
42
|
tenantId: ctx.service,
|
|
41
43
|
principalUrl: ctx.principal.url,
|
|
44
|
+
principalKind: ctx.principal.kind,
|
|
45
|
+
permissionBypass: isPermissionBypassPrincipal(ctx),
|
|
42
46
|
})
|
|
43
47
|
const headers = new Headers(request.headers)
|
|
44
48
|
headers.delete(`host`)
|