@segmentstream-cloud/ontology 0.13.0 → 0.15.0

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.
Files changed (51) hide show
  1. package/README.md +65 -3
  2. package/dist/agent-chat.d.ts +75 -0
  3. package/dist/agent-chat.js +176 -0
  4. package/dist/analyze.d.ts +6 -2
  5. package/dist/analyze.js +29 -18
  6. package/dist/authoring.d.ts +6 -3
  7. package/dist/authoring.js +6 -3
  8. package/dist/index.d.ts +7 -2
  9. package/dist/index.js +5 -1
  10. package/dist/migrate/apply.js +2 -1
  11. package/dist/migrate/plan.js +21 -9
  12. package/dist/migrate/storage.d.ts +12 -0
  13. package/dist/migrate/storage.js +48 -0
  14. package/dist/names.d.ts +1 -1
  15. package/dist/names.js +1 -0
  16. package/dist/permissions.d.ts +11 -2
  17. package/dist/permissions.js +15 -3
  18. package/dist/replication/baseline.js +1 -1
  19. package/dist/replication/model.js +2 -2
  20. package/dist/replication/schema.js +1 -1
  21. package/dist/runtime/agents.d.ts +10 -0
  22. package/dist/runtime/agents.js +305 -0
  23. package/dist/runtime/generated-actions.d.ts +2 -1
  24. package/dist/runtime/generated-actions.js +18 -13
  25. package/dist/runtime/service-binding.js +2 -0
  26. package/dist/service/agent/channel.d.ts +16 -0
  27. package/dist/service/agent/channel.js +88 -0
  28. package/dist/service/agent/protocol.d.ts +61 -0
  29. package/dist/service/agent/protocol.js +85 -0
  30. package/dist/service/agent/server.d.ts +28 -0
  31. package/dist/service/agent/server.js +257 -0
  32. package/dist/service/aggregate-platform.d.ts +5 -0
  33. package/dist/service/aggregate-platform.js +1 -1
  34. package/dist/service/authentication.d.ts +16 -5
  35. package/dist/service/authentication.js +51 -6
  36. package/dist/service/config.d.ts +29 -2
  37. package/dist/service/config.js +52 -2
  38. package/dist/service/errors.d.ts +6 -0
  39. package/dist/service/errors.js +16 -0
  40. package/dist/service/handlers.d.ts +2 -1
  41. package/dist/service/handlers.js +2 -2
  42. package/dist/service/mcp/server.d.ts +6 -0
  43. package/dist/service/mcp/server.js +15 -10
  44. package/dist/service/operations.d.ts +6 -2
  45. package/dist/service/operations.js +13 -5
  46. package/dist/service/runtime-contract.d.ts +94 -0
  47. package/dist/service/runtime-identity.d.ts +59 -0
  48. package/dist/service/runtime-identity.js +102 -0
  49. package/dist/service/service.d.ts +2 -2
  50. package/dist/service/service.js +24 -6
  51. package/package.json +1 -1
@@ -6,6 +6,7 @@ import type { Principal } from "../runtime.js";
6
6
  import type { NeonAuthEnvironment } from "./config.js";
7
7
  import { RequestId } from "./errors.js";
8
8
  import { OntologyBackend, type SignInIdentity } from "./runtime-contract.js";
9
+ import { type RuntimeCredential, type RuntimeTokenVerifier } from "./runtime-identity.js";
9
10
  declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@segmentstream-cloud/ontology/CurrentPrincipal", Principal>;
10
11
  /** The principal the request runs as, provided by {@link Authentication}. */
11
12
  export declare class CurrentPrincipal extends CurrentPrincipal_base {
@@ -23,9 +24,10 @@ declare const Authentication_base: HttpApiMiddleware.ServiceClass<Authentication
23
24
  readonly bearer: HttpApiSecurity.Http;
24
25
  }, CurrentPrincipal, readonly [import("effect/Schema").Top, import("effect/Schema").Top, import("effect/Schema").Top], RequestId>>;
25
26
  /**
26
- * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
27
- * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
28
- * Failures answer the request directly, before its input is decoded.
27
+ * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token,
28
+ * an API key or the deployment's runtime token acting as a member, all as `Authorization: Bearer …`;
29
+ * without one, the app's Neon Auth session cookie. Failures answer the request directly, before its
30
+ * input is decoded.
29
31
  */
30
32
  export declare class Authentication extends Authentication_base {
31
33
  }
@@ -115,6 +117,9 @@ type Authenticated = {
115
117
  readonly status: "rejected";
116
118
  } | {
117
119
  readonly status: "not_invited";
120
+ } | {
121
+ readonly status: "forbidden";
122
+ readonly detail: string;
118
123
  } | {
119
124
  readonly status: "unavailable";
120
125
  };
@@ -125,6 +130,11 @@ export interface CredentialOptions {
125
130
  * an access token is rejected, as it was issued for the MCP server only.
126
131
  */
127
132
  readonly oauthResource?: string;
133
+ /**
134
+ * The deployment's own runtime token may authenticate here, acting as the member it names. Without
135
+ * it a runtime token is just an invalid token.
136
+ */
137
+ readonly runtime?: RuntimeCredential;
128
138
  }
129
139
  /** Resolves a bearer credential to a principal. */
130
140
  export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, options?: CredentialOptions): Effect.Effect<Authenticated, never, OntologyBackend>;
@@ -147,7 +157,8 @@ export declare function withPrincipal<E, R>(credential: Redacted.Redacted<string
147
157
  }): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Exclude<R, CurrentPrincipal> | RequestId>;
148
158
  /**
149
159
  * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
150
- * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
160
+ * a request without a bearer token may authenticate with the app's Neon Auth session cookie; with
161
+ * `verifyRuntime`, the deployment's own runtime token may act as the member it names.
151
162
  */
152
- export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange): Layer.Layer<Authentication, never, OntologyBackend>;
163
+ export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange, verifyRuntime?: RuntimeTokenVerifier): Layer.Layer<Authentication, never, OntologyBackend>;
153
164
  export {};
@@ -3,9 +3,10 @@ import { HttpServerRequest } from "effect/unstable/http";
3
3
  import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi";
4
4
  import { createRemoteJWKSet, decodeJwt, errors, jwtVerify, } from "jose";
5
5
  import { API_KEY_PREFIX } from "../runtime/principals.js";
6
- import { RequestId, crossOriginSession, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
6
+ import { RequestId, crossOriginSession, forbidden, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
7
7
  import { ACCESS_TOKEN_PREFIX, principalOfAccessToken } from "./oauth/protocol.js";
8
8
  import { OntologyBackend } from "./runtime-contract.js";
9
+ import { ACT_AS_HEADER, logRuntimeRejection, runtimeCredential, } from "./runtime-identity.js";
9
10
  import { ErrorSchemas } from "./schemas.js";
10
11
  const CLOCK_TOLERANCE_SECONDS = 5;
11
12
  const JWKS_TIMEOUT_MS = 5_000;
@@ -13,9 +14,10 @@ const JWKS_TIMEOUT_MS = 5_000;
13
14
  export class CurrentPrincipal extends Context.Service()("@segmentstream-cloud/ontology/CurrentPrincipal") {
14
15
  }
15
16
  /**
16
- * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
17
- * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
18
- * Failures answer the request directly, before its input is decoded.
17
+ * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token,
18
+ * an API key or the deployment's runtime token acting as a member, all as `Authorization: Bearer …`;
19
+ * without one, the app's Neon Auth session cookie. Failures answer the request directly, before its
20
+ * input is decoded.
19
21
  */
20
22
  export class Authentication extends HttpApiMiddleware.Service()("@segmentstream-cloud/ontology/Authentication", {
21
23
  security: {
@@ -274,6 +276,29 @@ function sessionMayAuthenticate(request) {
274
276
  const origin = request.headers.origin;
275
277
  return origin !== undefined && origin === requestOrigin(request);
276
278
  }
279
+ /**
280
+ * The member a verified runtime caller acts for: the member `X-SegmentStream-Act-As` names, with the
281
+ * roles they hold now, while they are active and paired with the agent. Without the header, the
282
+ * runtime may call only the agent's own routes, which authenticate it themselves.
283
+ */
284
+ function actingMember(actAs) {
285
+ return Effect.gen(function* () {
286
+ const backend = yield* OntologyBackend;
287
+ if (actAs === undefined) {
288
+ return {
289
+ status: "forbidden",
290
+ detail: `The app's agent names the member it acts for in ${ACT_AS_HEADER}.`,
291
+ };
292
+ }
293
+ const member = backend.agents === undefined ? undefined : yield* backend.agents.actingMember(actAs);
294
+ return member === undefined
295
+ ? {
296
+ status: "forbidden",
297
+ detail: "The member is inactive or has not paired with the app's agent.",
298
+ }
299
+ : { status: "authenticated", principal: member };
300
+ });
301
+ }
277
302
  /** Resolves a bearer credential to a principal. */
278
303
  export function authenticate(credential, verifyToken, options = {}) {
279
304
  return Effect.gen(function* () {
@@ -296,6 +321,21 @@ export function authenticate(credential, verifyToken, options = {}) {
296
321
  ? { status: "rejected" }
297
322
  : { status: "authenticated", principal: service };
298
323
  }
324
+ if (options.runtime !== undefined) {
325
+ const { verify, actAs } = options.runtime;
326
+ const runtime = yield* Effect.promise(() => verify(token));
327
+ switch (runtime.status) {
328
+ case "valid":
329
+ return yield* actingMember(actAs);
330
+ case "invalid":
331
+ yield* logRuntimeRejection(runtime.reason);
332
+ return { status: "rejected" };
333
+ case "unavailable":
334
+ return { status: "unavailable" };
335
+ case "other":
336
+ break;
337
+ }
338
+ }
299
339
  const verification = yield* Effect.promise(() => verifyToken(token));
300
340
  if (verification.status !== "valid") {
301
341
  return verification.status === "invalid"
@@ -358,6 +398,8 @@ export function withPrincipal(credential, verifyToken, backend, effect, options
358
398
  return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked.", options.challenge?.(false)));
359
399
  case "not_invited":
360
400
  return toServerResponse(notInvited(requestId));
401
+ case "forbidden":
402
+ return toServerResponse(forbidden(requestId, outcome.detail));
361
403
  case "cross_origin":
362
404
  return toServerResponse(crossOriginSession(requestId));
363
405
  case "unavailable":
@@ -369,16 +411,19 @@ export function withPrincipal(credential, verifyToken, backend, effect, options
369
411
  }
370
412
  /**
371
413
  * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
372
- * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
414
+ * a request without a bearer token may authenticate with the app's Neon Auth session cookie; with
415
+ * `verifyRuntime`, the deployment's own runtime token may act as the member it names.
373
416
  */
374
- export function authenticationLayer(verifyToken, exchangeSession) {
417
+ export function authenticationLayer(verifyToken, exchangeSession, verifyRuntime) {
375
418
  return Layer.effect(Authentication, Effect.gen(function* () {
376
419
  const backend = yield* OntologyBackend;
377
420
  return Authentication.of({
378
421
  bearer: (httpEffect, { credential }) => Effect.gen(function* () {
379
422
  const request = yield* HttpServerRequest.HttpServerRequest;
423
+ const runtime = runtimeCredential(verifyRuntime, request);
380
424
  return yield* withPrincipal(credential, verifyToken, backend, httpEffect, {
381
425
  session: { request, exchange: exchangeSession },
426
+ ...(runtime === undefined ? {} : { runtime }),
382
427
  });
383
428
  }),
384
429
  });
@@ -3,6 +3,7 @@ import { BlobStorageBinding } from "../storage/provider.js";
3
3
  import type { Principal } from "../runtime.js";
4
4
  import { type ConnectionTransportFactory, type LocalConnectionContext } from "./connections.js";
5
5
  import type { OntologyServiceRuntime } from "./runtime-contract.js";
6
+ import type { RuntimeIdentityConfig } from "./runtime-identity.js";
6
7
  /** The environment of the workspace deployment's ontology service. */
7
8
  export declare const ENVIRONMENT_VARIABLES: {
8
9
  readonly databaseUrl: "SEGMENTSTREAM_ONTOLOGY_DATABASE_URL";
@@ -25,6 +26,22 @@ export declare const NEON_AUTH_ENVIRONMENT_VARIABLES: {
25
26
  readonly baseUrl: "NEON_AUTH_BASE_URL";
26
27
  readonly jwksUrl: "NEON_AUTH_JWKS_URL";
27
28
  };
29
+ /**
30
+ * The deployment's own runtime callers, such as the app's agent: the issuer and the audience of the
31
+ * team's Vercel OIDC tokens, set together by the platform on deployments that have an agent. Without
32
+ * them a runtime token is an invalid token. Tokens must also name the project in Vercel's
33
+ * `VERCEL_PROJECT_ID`, as the agent runs in the same Vercel project as the ontology service.
34
+ */
35
+ export declare const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES: {
36
+ readonly issuer: "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER";
37
+ readonly audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE";
38
+ readonly projectId: "VERCEL_PROJECT_ID";
39
+ };
40
+ /**
41
+ * The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
42
+ * takes the messages of `agent_chat.send_message` there.
43
+ */
44
+ export declare const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
28
45
  /** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
29
46
  export interface NeonAuthEnvironment {
30
47
  /** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
@@ -51,6 +68,16 @@ export interface OntologyEnvironment {
51
68
  readonly appCredential?: Redacted.Redacted<string>;
52
69
  /** Present once the workspace has Neon Auth: members then sign in with its tokens. */
53
70
  readonly neonAuth?: NeonAuthEnvironment;
71
+ /**
72
+ * Present on deployments with a runtime caller: the deployment's own Vercel OIDC tokens then
73
+ * authenticate, acting as the member `X-SegmentStream-Act-As` names.
74
+ */
75
+ readonly runtimeIdentity?: RuntimeIdentityConfig;
76
+ /**
77
+ * The agent's origin, `https://<VERCEL_PROJECT_PRODUCTION_URL>`, set with `runtimeIdentity`:
78
+ * `agent_chat.send_message` hands messages to the agent there.
79
+ */
80
+ readonly agentUrl?: string;
54
81
  /**
55
82
  * The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
56
83
  * call connections through SegmentStream with the deployment's Vercel OIDC token.
@@ -99,8 +126,8 @@ export declare class OntologyServiceConfigError extends OntologyServiceConfigErr
99
126
  /**
100
127
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
101
128
  * variable throws {@link OntologyServiceConfigError} naming every missing variable, and never the
102
- * value of the database URL. The Neon Auth variables are optional, but only as a pair. The app
103
- * credential is optional and kept redacted.
129
+ * value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
130
+ * pairs. The app credential is optional and kept redacted.
104
131
  */
105
132
  export declare function configFromEnv(environment?: Readonly<Record<string, string | undefined>>): OntologyEnvironment;
106
133
  export {};
@@ -23,6 +23,22 @@ export const NEON_AUTH_ENVIRONMENT_VARIABLES = {
23
23
  baseUrl: "NEON_AUTH_BASE_URL",
24
24
  jwksUrl: "NEON_AUTH_JWKS_URL",
25
25
  };
26
+ /**
27
+ * The deployment's own runtime callers, such as the app's agent: the issuer and the audience of the
28
+ * team's Vercel OIDC tokens, set together by the platform on deployments that have an agent. Without
29
+ * them a runtime token is an invalid token. Tokens must also name the project in Vercel's
30
+ * `VERCEL_PROJECT_ID`, as the agent runs in the same Vercel project as the ontology service.
31
+ */
32
+ export const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES = {
33
+ issuer: "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER",
34
+ audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE",
35
+ projectId: "VERCEL_PROJECT_ID",
36
+ };
37
+ /**
38
+ * The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
39
+ * takes the messages of `agent_chat.send_message` there.
40
+ */
41
+ export const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
26
42
  /** The service cannot start: its configuration is incomplete or invalid. */
27
43
  export class OntologyServiceConfigError extends Schema.TaggedError()("OntologyServiceConfigError", { message: Schema.String }) {
28
44
  }
@@ -60,6 +76,35 @@ function neonAuthFromEnv(read) {
60
76
  jwksUrl: httpUrl(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl, read(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl)).href,
61
77
  };
62
78
  }
79
+ /**
80
+ * The runtime issuer and audience, both or neither, and the project the tokens must name. A partial
81
+ * configuration is a broken deployment, so the service refuses to start rather than run without the
82
+ * agent's identity, or trust tokens of every project of the team.
83
+ */
84
+ function runtimeIdentityFromEnv(read) {
85
+ const { issuer, audience, projectId } = RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES;
86
+ const pair = [issuer, audience];
87
+ const missingPair = pair.filter((name) => read(name) === "");
88
+ if (missingPair.length === pair.length) {
89
+ return undefined;
90
+ }
91
+ if (missingPair.length > 0) {
92
+ throw new OntologyServiceConfigError({
93
+ message: `The ontology service cannot start: ${pair.join(" and ")} are set together; ` +
94
+ `${missingPair.join(", ")} is not set.`,
95
+ });
96
+ }
97
+ if (read(projectId) === "") {
98
+ throw new OntologyServiceConfigError({
99
+ message: `The ontology service cannot start: ${projectId} is not set, which runtime tokens must name.`,
100
+ });
101
+ }
102
+ return {
103
+ issuer: httpUrl(issuer, read(issuer)).href.replace(/\/+$/, ""),
104
+ audience: read(audience),
105
+ projectId: read(projectId),
106
+ };
107
+ }
63
108
  function checkDatabaseUrl(name, value) {
64
109
  // The value is a credential: errors name the variable, never the value.
65
110
  if (!URL.canParse(value)) {
@@ -76,8 +121,8 @@ function checkDatabaseUrl(name, value) {
76
121
  /**
77
122
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
78
123
  * variable throws {@link OntologyServiceConfigError} naming every missing variable, and never the
79
- * value of the database URL. The Neon Auth variables are optional, but only as a pair. The app
80
- * credential is optional and kept redacted.
124
+ * value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
125
+ * pairs. The app credential is optional and kept redacted.
81
126
  */
82
127
  export function configFromEnv(environment = process.env) {
83
128
  const read = (name) => environment[name]?.trim() ?? "";
@@ -90,6 +135,7 @@ export function configFromEnv(environment = process.env) {
90
135
  });
91
136
  }
92
137
  const neonAuth = neonAuthFromEnv(read);
138
+ const runtimeIdentity = runtimeIdentityFromEnv(read);
93
139
  const localConnections = localConnectionsFromEnv(environment);
94
140
  let blobStorage;
95
141
  if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
@@ -118,6 +164,10 @@ export function configFromEnv(environment = process.env) {
118
164
  ? { appCredential: Redacted.make(read(APP_CREDENTIAL_VARIABLE)) }
119
165
  : {}),
120
166
  ...(neonAuth === undefined ? {} : { neonAuth }),
167
+ ...(runtimeIdentity === undefined ? {} : { runtimeIdentity }),
168
+ ...(runtimeIdentity === undefined || read(AGENT_HOST_VARIABLE) === ""
169
+ ? {}
170
+ : { agentUrl: normalizeUrl(AGENT_HOST_VARIABLE, `https://${read(AGENT_HOST_VARIABLE)}`) }),
121
171
  ...(localConnections === undefined ? {} : { localConnections }),
122
172
  };
123
173
  }
@@ -92,6 +92,12 @@ export declare function notInvited(requestId: string): ErrorResponse;
92
92
  * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
93
93
  */
94
94
  export declare function crossOriginSession(requestId: string): ErrorResponse;
95
+ /** The caller is authenticated but may not do this; the detail says why. */
96
+ export declare function forbidden(requestId: string, detail: string): ErrorResponse;
97
+ /** A client asked too often; it may ask again after `retryAfterSeconds`. */
98
+ export declare function rateLimited(requestId: string, detail: string, retryAfterSeconds: number): ErrorResponse;
99
+ /** The resource does not exist, or not for this caller; the detail says what to do. */
100
+ export declare function notFound(requestId: string, detail: string): ErrorResponse;
95
101
  export declare function unavailable(requestId: string): ErrorResponse;
96
102
  export declare function internalError(requestId: string): ErrorResponse;
97
103
  export declare function routeNotFound(requestId: string): ErrorResponse;
@@ -129,6 +129,22 @@ export function crossOriginSession(requestId) {
129
129
  },
130
130
  };
131
131
  }
132
+ /** The caller is authenticated but may not do this; the detail says why. */
133
+ export function forbidden(requestId, detail) {
134
+ return { status: 403, body: { error: "forbidden", detail, requestId } };
135
+ }
136
+ /** A client asked too often; it may ask again after `retryAfterSeconds`. */
137
+ export function rateLimited(requestId, detail, retryAfterSeconds) {
138
+ return {
139
+ status: 429,
140
+ body: { error: "rate_limited", detail, requestId },
141
+ headers: { "retry-after": String(retryAfterSeconds) },
142
+ };
143
+ }
144
+ /** The resource does not exist, or not for this caller; the detail says what to do. */
145
+ export function notFound(requestId, detail) {
146
+ return { status: 404, body: { error: "not_found", detail, requestId } };
147
+ }
132
148
  export function unavailable(requestId) {
133
149
  return {
134
150
  status: 503,
@@ -4,6 +4,7 @@ import type { Aggregates } from "./aggregate.js";
4
4
  import { type BuiltApi } from "./api.js";
5
5
  import type { AfterCommitHook } from "./config.js";
6
6
  import type { ReplicationKick } from "./replication.js";
7
+ import type { AgentChannelFactory } from "./agent/channel.js";
7
8
  import type { ConnectionTransportFactory } from "./connections.js";
8
9
  import { type RateLimit } from "./rate-limit.js";
9
10
  import { OntologyBackend } from "./runtime-contract.js";
@@ -13,4 +14,4 @@ import { OntologyBackend } from "./runtime-contract.js";
13
14
  */
14
15
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
15
16
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
16
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
17
+ export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates, agentChannel: AgentChannelFactory | undefined): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
@@ -216,8 +216,8 @@ function storageHandlers(built) {
216
216
  }));
217
217
  }
218
218
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
219
- export function handlersLayer(built, hooks, connections, replicate, aggregates) {
220
- const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
219
+ export function handlersLayer(built, hooks, connections, replicate, aggregates, agentChannel) {
220
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate, agentChannel);
221
221
  const layers = [
222
222
  serviceHandlers(built, operationsOf, aggregates.model),
223
223
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
@@ -4,10 +4,12 @@ import type { Aggregates } from "../aggregate.js";
4
4
  import type { BuiltApi } from "../api.js";
5
5
  import { type TokenVerifier } from "../authentication.js";
6
6
  import type { AfterCommitHook } from "../config.js";
7
+ import type { AgentChannelFactory } from "../agent/channel.js";
7
8
  import type { ConnectionTransportFactory } from "../connections.js";
8
9
  import { RequestId } from "../errors.js";
9
10
  import type { ReplicationKick } from "../replication.js";
10
11
  import { OntologyBackend } from "../runtime-contract.js";
12
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
11
13
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
12
14
  export declare const MCP_PATH = "/mcp";
13
15
  /** The MCP revisions the server speaks, newest first. */
@@ -18,8 +20,12 @@ export interface McpServerOptions {
18
20
  readonly connections: ConnectionTransportFactory;
19
21
  readonly replicate: ReplicationKick;
20
22
  readonly verifyToken: TokenVerifier;
23
+ /** The deployment's runtime tokens, acting as a member; `undefined` while that is disabled. */
24
+ readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
21
25
  /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
22
26
  readonly aggregates: Aggregates;
27
+ /** The workspace's agent, for `agent_chat.send_message`; `undefined` without one. */
28
+ readonly agentChannel?: AgentChannelFactory | undefined;
23
29
  }
24
30
  /**
25
31
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
@@ -12,6 +12,7 @@ import { publicOrigin } from "../oauth/protocol.js";
12
12
  import { mcpChallenge, mcpResource } from "../oauth/server.js";
13
13
  import { identityToken, makeOperations, toQuery } from "../operations.js";
14
14
  import { OntologyBackend } from "../runtime-contract.js";
15
+ import { runtimeCredential } from "../runtime-identity.js";
15
16
  import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
16
17
  import { mcpTools, visibleTools } from "./tools.js";
17
18
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
@@ -346,7 +347,7 @@ function crossOrigin(request) {
346
347
  return !URL.canParse(origin) || new URL(origin).host !== new URL(publicOrigin(request)).host;
347
348
  }
348
349
  /** `POST /mcp`: one JSON-RPC message in, one JSON response out, nothing kept between requests. */
349
- function handlePost(server, verifyToken, backend) {
350
+ function handlePost(server, verifyToken, verifyRuntime, backend) {
350
351
  const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
351
352
  return (request) => Effect.gen(function* () {
352
353
  const requestId = yield* RequestId;
@@ -389,14 +390,18 @@ function handlePost(server, verifyToken, backend) {
389
390
  }
390
391
  });
391
392
  const origin = publicOrigin(request);
393
+ const runtime = runtimeCredential(verifyRuntime, request);
392
394
  // With the OAuth tables, members' access tokens work here, and a 401 tells the client where
393
- // the member authorizes it.
394
- return yield* withPrincipal(yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request), verifyToken, backend, answer, backend.oauth === undefined
395
- ? {}
396
- : {
397
- oauthResource: mcpResource(origin),
398
- challenge: (missing) => mcpChallenge(origin, missing),
399
- });
395
+ // the member authorizes it. The app's agent calls as the member it names.
396
+ return yield* withPrincipal(yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request), verifyToken, backend, answer, {
397
+ ...(runtime === undefined ? {} : { runtime }),
398
+ ...(backend.oauth === undefined
399
+ ? {}
400
+ : {
401
+ oauthResource: mcpResource(origin),
402
+ challenge: (missing) => mcpChallenge(origin, missing),
403
+ }),
404
+ });
400
405
  });
401
406
  }
402
407
  /**
@@ -412,10 +417,10 @@ export function mcpLayer(options) {
412
417
  const server = {
413
418
  built: options.built,
414
419
  tools,
415
- operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
420
+ operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
416
421
  aggregate: options.aggregates.model,
417
422
  };
418
- yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
423
+ yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
419
424
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
420
425
  yield* router.add("DELETE", MCP_PATH, METHOD_NOT_ALLOWED);
421
426
  }));
@@ -5,6 +5,7 @@ import type { Query, QueryResult } from "../query.js";
5
5
  import { CurrentPrincipal } from "./authentication.js";
6
6
  import type { AfterCommitHook } from "./config.js";
7
7
  import type { ReplicationKick } from "./replication.js";
8
+ import type { AgentChannelFactory } from "./agent/channel.js";
8
9
  import { type ConnectionTransportFactory } from "./connections.js";
9
10
  import { InvalidActionInput, RequestId } from "./errors.js";
10
11
  import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
@@ -59,8 +60,11 @@ export interface Caller {
59
60
  readonly permissions: ReadonlyArray<string>;
60
61
  }
61
62
  type Backend = OntologyBackend["Service"];
62
- /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
63
- export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): {
63
+ /**
64
+ * The operations of a planned ontology, bound to its backend, after-commit hooks, connections and,
65
+ * on deployments with one, the workspace's agent.
66
+ */
67
+ export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, agentChannel?: AgentChannelFactory): {
64
68
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
65
69
  find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
66
70
  /** One record. Requires `read` on the entity. */
@@ -6,6 +6,7 @@ import { PermissionDenied } from "../errors.js";
6
6
  import { toCamelCase } from "../names.js";
7
7
  import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
8
8
  import { CurrentPrincipal } from "./authentication.js";
9
+ import { AgentChannel } from "../agent-chat.js";
9
10
  import { OIDC_TOKEN_HEADER } from "./connections.js";
10
11
  import { InvalidActionInput, RequestId } from "./errors.js";
11
12
  /**
@@ -145,12 +146,12 @@ function traverseOf(backend, targets, planned, traversal, id, query) {
145
146
  return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
146
147
  });
147
148
  }
148
- function runActionOf(backend, ontology, hooks, connections, replicate) {
149
+ function runActionOf(backend, ontology, hooks, connections, replicate, agentChannel) {
149
150
  return (planned, { name, generated }, call) => Effect.gen(function* () {
150
151
  const principal = yield* CurrentPrincipal;
151
152
  const requestId = yield* RequestId;
152
153
  const hook = hooks.get(`${planned.name}.${name}`);
153
- const outcome = yield* backend.runAction({
154
+ const run = backend.runAction({
154
155
  entity: planned.name,
155
156
  action: name,
156
157
  input: call.input,
@@ -175,6 +176,10 @@ function runActionOf(backend, ontology, hooks, connections, replicate) {
175
176
  }
176
177
  : {}),
177
178
  });
179
+ // Built-in actions reach the workspace's agent through it, when the deployment has one.
180
+ const outcome = yield* agentChannel === undefined
181
+ ? run
182
+ : run.pipe(Effect.provideService(AgentChannel, agentChannel({ identityToken: call.identityToken, store: backend.agents })));
178
183
  const { issues } = outcome.validation;
179
184
  if (!outcome.validation.ok && !call.validateOnly) {
180
185
  return yield* new InvalidActionInput({ issues });
@@ -208,8 +213,11 @@ function callerOf(backend) {
208
213
  };
209
214
  });
210
215
  }
211
- /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
212
- export function makeOperations(backend, plan, hooks, connections, replicate) {
216
+ /**
217
+ * The operations of a planned ontology, bound to its backend, after-commit hooks, connections and,
218
+ * on deployments with one, the workspace's agent.
219
+ */
220
+ export function makeOperations(backend, plan, hooks, connections, replicate, agentChannel) {
213
221
  const targets = relationshipTargets(plan);
214
222
  return {
215
223
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
@@ -221,7 +229,7 @@ export function makeOperations(backend, plan, hooks, connections, replicate) {
221
229
  /** The records a relationship reaches from one record, as a page of the target entity. */
222
230
  traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
223
231
  /** Runs an action as the caller, with the action's after-commit hook and connections. */
224
- runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate),
232
+ runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate, agentChannel),
225
233
  /** The caller's user, roles and permissions. It needs no permission. */
226
234
  caller: callerOf(backend),
227
235
  };
@@ -153,6 +153,98 @@ export interface OAuthStore {
153
153
  /** The member a grant acts for, with the roles they hold now; undefined once they may not sign in. */
154
154
  readonly memberPrincipal: (userId: string) => Effect.Effect<Principal | undefined>;
155
155
  }
156
+ /** A channel account waiting, for ten minutes, for a member to claim it with its pairing code. */
157
+ export interface AgentPairing {
158
+ readonly id: string;
159
+ /** The channel, such as `telegram`. */
160
+ readonly provider: string;
161
+ /** The account's id on the channel. */
162
+ readonly externalId: string;
163
+ /** How the channel names the account, shown on the connect page. */
164
+ readonly display: string | null;
165
+ readonly expiresAt: Date;
166
+ }
167
+ /** A channel account paired with a member: the app's agent acts as the member for its messages. */
168
+ export interface AgentIdentity {
169
+ readonly id: string;
170
+ readonly provider: string;
171
+ readonly externalId: string;
172
+ readonly userId: string;
173
+ readonly display: string | null;
174
+ readonly createdAt: Date;
175
+ /** When the identity stopped working; null while it is live. */
176
+ readonly revokedAt: Date | null;
177
+ }
178
+ /** A member as the agent names them: their user's name and the roles they hold now. */
179
+ export interface AgentMember {
180
+ readonly name: string;
181
+ readonly principal: Principal;
182
+ }
183
+ /** A group chat the agent saw, as `POST /api/ontology/agent/chats/seen` answers. */
184
+ export interface SeenChat {
185
+ readonly id: string;
186
+ readonly title: string;
187
+ readonly purpose: string | null;
188
+ readonly whenToUse: string | null;
189
+ }
190
+ /**
191
+ * Where the app's agent keeps pairing codes and the identities they create: the runtime's agent
192
+ * tables in the workspace database. Codes are stored only as SHA-256 hashes. Expiry is checked
193
+ * against the database's clock; failures are defects.
194
+ */
195
+ export interface AgentStore {
196
+ /** Stores a pairing under its code's hash and forgets expired ones; false when the hash is taken. */
197
+ readonly savePairing: (pairing: AgentPairing & {
198
+ readonly codeHash: string;
199
+ }) => Effect.Effect<boolean>;
200
+ /** A pairing that has neither expired nor been approved. */
201
+ readonly findPairing: (codeHash: string) => Effect.Effect<AgentPairing | undefined>;
202
+ /**
203
+ * Consumes a live pairing, once, and pairs its account with the member. The account's previous
204
+ * identity and the member's previous identity on the same provider are revoked: each has one live
205
+ * identity per provider. `undefined` when the pairing expired or was already approved.
206
+ */
207
+ readonly approvePairing: (input: {
208
+ readonly codeHash: string;
209
+ readonly userId: string;
210
+ readonly identityId: string;
211
+ }) => Effect.Effect<AgentIdentity | undefined>;
212
+ /** The account's live identity, else its latest revoked one, else `undefined`. */
213
+ readonly findIdentity: (input: {
214
+ readonly provider: string;
215
+ readonly externalId: string;
216
+ }) => Effect.Effect<AgentIdentity | undefined>;
217
+ /** The member's live identities, oldest first. */
218
+ readonly listIdentities: (userId: string) => Effect.Effect<ReadonlyArray<AgentIdentity>>;
219
+ /** Revokes one of the member's live identities; false when they have no such identity. */
220
+ readonly revokeIdentity: (input: {
221
+ readonly id: string;
222
+ readonly userId: string;
223
+ }) => Effect.Effect<boolean>;
224
+ /** An active member by user id; `undefined` for an unknown user, a service or an inactive member. */
225
+ readonly member: (userId: string) => Effect.Effect<AgentMember | undefined>;
226
+ /** The member a runtime caller acts for: active, and with a live identity. */
227
+ readonly actingMember: (userId: string) => Effect.Effect<Principal | undefined>;
228
+ /**
229
+ * Records a message the agent saw in a group chat: creates the `agent_chat` or keeps its title
230
+ * current, records when the chat last had a message (at most every 15 minutes, so that history
231
+ * keeps it without a row per message), links the writer to its members when they are an active
232
+ * member, and unlinks `leftMemberId`, a member who left, when linked. With `agentLeft`, the agent
233
+ * itself left the chat: every member is unlinked, and the chat stays. With `migratedFrom`, the
234
+ * chat had that id before it became a supergroup: its record moves to `externalId`, or, when both
235
+ * ids have one, the new record takes the old one's members, and its purpose and when to use where
236
+ * it has none. Concurrent calls for one chat leave one record.
237
+ */
238
+ readonly seeChat: (input: {
239
+ readonly provider: string;
240
+ readonly externalId: string;
241
+ readonly title: string;
242
+ readonly memberId?: string;
243
+ readonly leftMemberId?: string;
244
+ readonly agentLeft?: true;
245
+ readonly migratedFrom?: string;
246
+ }) => Effect.Effect<SeenChat>;
247
+ }
156
248
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
157
249
  readonly blobs?: BlobRuntime;
158
250
  readonly maintainBlobs?: Effect.Effect<{
@@ -192,6 +284,8 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
192
284
  readonly replicationEnabled?: Effect.Effect<boolean>;
193
285
  /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
194
286
  readonly oauth?: OAuthStore;
287
+ /** The agent tables, which pair channel accounts with members for the app's own agent. */
288
+ readonly agents?: AgentStore;
195
289
  }>;
196
290
  /**
197
291
  * The runtime as the HTTP service sees it, with its database and ontology already bound. Failures