@segmentstream-cloud/ontology 0.13.0 → 0.14.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.
@@ -0,0 +1,235 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect, Layer, Redacted, Result, Schema } from "effect";
3
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
5
+ import { schemaIssues } from "../../runtime/validation.js";
6
+ import { BASE_PATH } from "../api.js";
7
+ import { CurrentPrincipal, withPrincipal, } from "../authentication.js";
8
+ import { RequestId, errorBodyForWire, forbidden, notFound, rateLimited, toServerResponse, unauthorized, unavailable, } from "../errors.js";
9
+ import { secretHash } from "../oauth/protocol.js";
10
+ import { clientAddress, fixedWindowLimiter, } from "../rate-limit.js";
11
+ import { OntologyBackend } from "../runtime-contract.js";
12
+ import { logRuntimeRejection } from "../runtime-identity.js";
13
+ import { PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
14
+ /** The agent's endpoints, under the ontology service's path. */
15
+ export const AGENT_PATH = `${BASE_PATH}/agent`;
16
+ const PAIRINGS_PATH = `${AGENT_PATH}/pairings`;
17
+ const IDENTITIES_PATH = `${AGENT_PATH}/identities`;
18
+ /**
19
+ * Pairings one client address may start per minute; each one is a row in the database. The agent's
20
+ * functions share a few egress addresses, so the limit leaves room for a busy channel.
21
+ */
22
+ export const PAIRING_LIMIT = { limit: 60, windowMs: 60_000, maxKeys: 10_000 };
23
+ /** A fresh code collides with a live one about never; a few attempts make it never in practice. */
24
+ const CODE_ATTEMPTS = 3;
25
+ const NO_STORE = { "cache-control": "no-store" };
26
+ function json(body, status = 200) {
27
+ return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
28
+ }
29
+ function jsonBody(request) {
30
+ return Effect.map(Effect.orDie(request.text), (text) => {
31
+ try {
32
+ return JSON.parse(text);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ });
38
+ }
39
+ function invalidBody(requestId, issues) {
40
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire({
41
+ error: "invalid_request",
42
+ detail: "The request body is invalid.",
43
+ requestId,
44
+ issues,
45
+ }), { status: 400 });
46
+ }
47
+ function pairingNotFound(requestId) {
48
+ return toServerResponse(notFound(requestId, "The pairing code expired or was already used. Send the agent a message for a new link."));
49
+ }
50
+ function routeParam(name) {
51
+ return Effect.map(HttpRouter.params, (params) => params[name] ?? "");
52
+ }
53
+ /**
54
+ * `POST /api/ontology/agent/pairings` (runtime): a sender the agent does not know gets a short code
55
+ * that a signed-in member approves on the app's connect page. Only the code's hash is stored.
56
+ */
57
+ function createPairing(store, limiter) {
58
+ return (request) => Effect.gen(function* () {
59
+ const requestId = yield* RequestId;
60
+ const retryAfter = limiter.take(clientAddress(request));
61
+ if (retryAfter !== undefined) {
62
+ return toServerResponse(rateLimited(requestId, "Too many pairings; try again in a minute.", retryAfter));
63
+ }
64
+ const body = yield* jsonBody(request);
65
+ const decoded = Schema.decodeUnknownResult(PairingRequest)(body, {
66
+ errors: "all",
67
+ onExcessProperty: "error",
68
+ });
69
+ if (Result.isFailure(decoded)) {
70
+ return invalidBody(requestId, schemaIssues(decoded.failure));
71
+ }
72
+ const { provider, external_id: externalId, display } = decoded.success;
73
+ const trimmedDisplay = display?.trim() || null;
74
+ for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) {
75
+ const code = newPairingCode();
76
+ const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
77
+ const saved = yield* store.savePairing({
78
+ id: randomUUID(),
79
+ codeHash: secretHash(code),
80
+ provider,
81
+ externalId,
82
+ display: trimmedDisplay,
83
+ expiresAt,
84
+ });
85
+ if (saved) {
86
+ return json({ code, expires_at: expiresAt.toISOString() }, 201);
87
+ }
88
+ }
89
+ return yield* Effect.die(new Error("No free pairing code after several attempts."));
90
+ });
91
+ }
92
+ /** `GET /api/ontology/agent/pairings/:code` (member): what the connect page shows. */
93
+ function describePairing(store) {
94
+ return Effect.gen(function* () {
95
+ const requestId = yield* RequestId;
96
+ const code = canonicalPairingCode(yield* routeParam("code"));
97
+ const pairing = code === undefined ? undefined : yield* store.findPairing(secretHash(code));
98
+ if (pairing === undefined) {
99
+ return pairingNotFound(requestId);
100
+ }
101
+ return json({
102
+ provider: pairing.provider,
103
+ display: pairing.display,
104
+ expires_at: pairing.expiresAt.toISOString(),
105
+ });
106
+ });
107
+ }
108
+ /**
109
+ * `POST /api/ontology/agent/pairings/:code/approve` (member): pairs the account with the signed-in
110
+ * member, once. The account's previous identity and the member's previous identity on the same
111
+ * channel stop working: each has one live identity per channel, so an approval tricked out of a
112
+ * member displaces their own account, which they notice.
113
+ */
114
+ function approvePairing(store, member) {
115
+ return Effect.gen(function* () {
116
+ const requestId = yield* RequestId;
117
+ const code = canonicalPairingCode(yield* routeParam("code"));
118
+ const identity = code === undefined
119
+ ? undefined
120
+ : yield* store.approvePairing({
121
+ codeHash: secretHash(code),
122
+ userId: member.id,
123
+ identityId: randomUUID(),
124
+ });
125
+ if (identity === undefined) {
126
+ return pairingNotFound(requestId);
127
+ }
128
+ yield* Effect.logInfo("ontology.agent.paired").pipe(Effect.annotateLogs({ identityId: identity.id, provider: identity.provider, requestId }));
129
+ return json({ identity: identityForWire(identity) });
130
+ });
131
+ }
132
+ /**
133
+ * `GET /api/ontology/agent/identities/:provider/:external_id` (runtime): the member an account acts
134
+ * as, with the roles they hold now. 404 for an account nobody paired; 403 once its identity was
135
+ * revoked or its member deactivated.
136
+ */
137
+ function resolveIdentity(store) {
138
+ return Effect.gen(function* () {
139
+ const requestId = yield* RequestId;
140
+ const provider = yield* routeParam("provider");
141
+ const externalId = yield* routeParam("external_id");
142
+ const identity = yield* store.findIdentity({ provider, externalId });
143
+ if (identity === undefined) {
144
+ return toServerResponse(notFound(requestId, "No member paired this account."));
145
+ }
146
+ if (identity.revokedAt !== null) {
147
+ return toServerResponse(forbidden(requestId, "The account's pairing was revoked."));
148
+ }
149
+ const member = yield* store.member(identity.userId);
150
+ if (member === undefined) {
151
+ return toServerResponse(forbidden(requestId, "The member is inactive."));
152
+ }
153
+ return json(memberForWire(member));
154
+ });
155
+ }
156
+ /** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
157
+ function listIdentities(store, member) {
158
+ return Effect.gen(function* () {
159
+ const identities = yield* store.listIdentities(member.id);
160
+ return json({ identities: identities.map(identityForWire) });
161
+ });
162
+ }
163
+ /** `DELETE /api/ontology/agent/identities/:id` (member): revokes one of the member's identities. */
164
+ function revokeIdentity(store, member) {
165
+ return Effect.gen(function* () {
166
+ const requestId = yield* RequestId;
167
+ const id = yield* routeParam("id");
168
+ const revoked = yield* store.revokeIdentity({ id, userId: member.id });
169
+ if (!revoked) {
170
+ return toServerResponse(notFound(requestId, "You have no such identity."));
171
+ }
172
+ yield* Effect.logInfo("ontology.agent.unpaired").pipe(Effect.annotateLogs({ identityId: id, requestId }));
173
+ return HttpServerResponse.empty({ status: 204, headers: NO_STORE });
174
+ });
175
+ }
176
+ /**
177
+ * The routes that pair channel accounts with members for the app's own agent. Runtime routes take
178
+ * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
179
+ * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
180
+ * Without the agent tables, it serves nothing.
181
+ */
182
+ export function agentLayer(options) {
183
+ return Layer.effectDiscard(Effect.gen(function* () {
184
+ const router = yield* HttpRouter.HttpRouter;
185
+ const backend = yield* OntologyBackend;
186
+ const store = backend.agents;
187
+ if (store === undefined) {
188
+ return;
189
+ }
190
+ const limiter = fixedWindowLimiter(PAIRING_LIMIT);
191
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
192
+ const bearerOf = (request) => Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
193
+ const asRuntime = (answer) => (request) => Effect.gen(function* () {
194
+ const requestId = yield* RequestId;
195
+ const bearer = yield* bearerOf(request);
196
+ const token = Redacted.value(bearer);
197
+ const verified = options.verifyRuntime === undefined || token === ""
198
+ ? undefined
199
+ : yield* Effect.promise(() => options.verifyRuntime(token));
200
+ switch (verified?.status) {
201
+ case "valid":
202
+ return yield* answer(request);
203
+ case "invalid":
204
+ yield* logRuntimeRejection(verified.reason);
205
+ return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
206
+ case "unavailable":
207
+ return toServerResponse(unavailable(requestId));
208
+ default:
209
+ // Any other credential is authenticated as usual, so that it gets the usual 401, and
210
+ // then refused: these routes are the agent's.
211
+ return yield* withPrincipal(bearer, options.verifyToken, backend, Effect.succeed(toServerResponse(forbidden(requestId, "Only the app's agent calls this route."))));
212
+ }
213
+ });
214
+ const asMember = (answer) => (request) => Effect.gen(function* () {
215
+ const bearer = yield* bearerOf(request);
216
+ const decided = Effect.gen(function* () {
217
+ const requestId = yield* RequestId;
218
+ const principal = yield* CurrentPrincipal;
219
+ if (principal.kind !== "member") {
220
+ return toServerResponse(forbidden(requestId, "Only a signed-in member can pair the app's agent."));
221
+ }
222
+ return yield* answer(request, principal);
223
+ });
224
+ return yield* withPrincipal(bearer, options.verifyToken, backend, decided, {
225
+ session: { request, exchange: options.exchangeSession },
226
+ });
227
+ });
228
+ yield* router.add("POST", PAIRINGS_PATH, asRuntime(createPairing(store, limiter)));
229
+ yield* router.add("GET", `${PAIRINGS_PATH}/:code`, asMember(() => describePairing(store)));
230
+ yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
231
+ yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
232
+ yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
233
+ yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
234
+ }));
235
+ }
@@ -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,17 @@ 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
+ };
28
40
  /** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
29
41
  export interface NeonAuthEnvironment {
30
42
  /** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
@@ -51,6 +63,11 @@ export interface OntologyEnvironment {
51
63
  readonly appCredential?: Redacted.Redacted<string>;
52
64
  /** Present once the workspace has Neon Auth: members then sign in with its tokens. */
53
65
  readonly neonAuth?: NeonAuthEnvironment;
66
+ /**
67
+ * Present on deployments with a runtime caller: the deployment's own Vercel OIDC tokens then
68
+ * authenticate, acting as the member `X-SegmentStream-Act-As` names.
69
+ */
70
+ readonly runtimeIdentity?: RuntimeIdentityConfig;
54
71
  /**
55
72
  * The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
56
73
  * call connections through SegmentStream with the deployment's Vercel OIDC token.
@@ -99,8 +116,8 @@ export declare class OntologyServiceConfigError extends OntologyServiceConfigErr
99
116
  /**
100
117
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
101
118
  * 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.
119
+ * value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
120
+ * pairs. The app credential is optional and kept redacted.
104
121
  */
105
122
  export declare function configFromEnv(environment?: Readonly<Record<string, string | undefined>>): OntologyEnvironment;
106
123
  export {};
@@ -23,6 +23,17 @@ 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
+ };
26
37
  /** The service cannot start: its configuration is incomplete or invalid. */
27
38
  export class OntologyServiceConfigError extends Schema.TaggedError()("OntologyServiceConfigError", { message: Schema.String }) {
28
39
  }
@@ -60,6 +71,35 @@ function neonAuthFromEnv(read) {
60
71
  jwksUrl: httpUrl(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl, read(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl)).href,
61
72
  };
62
73
  }
74
+ /**
75
+ * The runtime issuer and audience, both or neither, and the project the tokens must name. A partial
76
+ * configuration is a broken deployment, so the service refuses to start rather than run without the
77
+ * agent's identity, or trust tokens of every project of the team.
78
+ */
79
+ function runtimeIdentityFromEnv(read) {
80
+ const { issuer, audience, projectId } = RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES;
81
+ const pair = [issuer, audience];
82
+ const missingPair = pair.filter((name) => read(name) === "");
83
+ if (missingPair.length === pair.length) {
84
+ return undefined;
85
+ }
86
+ if (missingPair.length > 0) {
87
+ throw new OntologyServiceConfigError({
88
+ message: `The ontology service cannot start: ${pair.join(" and ")} are set together; ` +
89
+ `${missingPair.join(", ")} is not set.`,
90
+ });
91
+ }
92
+ if (read(projectId) === "") {
93
+ throw new OntologyServiceConfigError({
94
+ message: `The ontology service cannot start: ${projectId} is not set, which runtime tokens must name.`,
95
+ });
96
+ }
97
+ return {
98
+ issuer: httpUrl(issuer, read(issuer)).href.replace(/\/+$/, ""),
99
+ audience: read(audience),
100
+ projectId: read(projectId),
101
+ };
102
+ }
63
103
  function checkDatabaseUrl(name, value) {
64
104
  // The value is a credential: errors name the variable, never the value.
65
105
  if (!URL.canParse(value)) {
@@ -76,8 +116,8 @@ function checkDatabaseUrl(name, value) {
76
116
  /**
77
117
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
78
118
  * 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.
119
+ * value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
120
+ * pairs. The app credential is optional and kept redacted.
81
121
  */
82
122
  export function configFromEnv(environment = process.env) {
83
123
  const read = (name) => environment[name]?.trim() ?? "";
@@ -90,6 +130,7 @@ export function configFromEnv(environment = process.env) {
90
130
  });
91
131
  }
92
132
  const neonAuth = neonAuthFromEnv(read);
133
+ const runtimeIdentity = runtimeIdentityFromEnv(read);
93
134
  const localConnections = localConnectionsFromEnv(environment);
94
135
  let blobStorage;
95
136
  if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
@@ -118,6 +159,7 @@ export function configFromEnv(environment = process.env) {
118
159
  ? { appCredential: Redacted.make(read(APP_CREDENTIAL_VARIABLE)) }
119
160
  : {}),
120
161
  ...(neonAuth === undefined ? {} : { neonAuth }),
162
+ ...(runtimeIdentity === undefined ? {} : { runtimeIdentity }),
121
163
  ...(localConnections === undefined ? {} : { localConnections }),
122
164
  };
123
165
  }
@@ -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,
@@ -8,6 +8,7 @@ import type { ConnectionTransportFactory } from "../connections.js";
8
8
  import { RequestId } from "../errors.js";
9
9
  import type { ReplicationKick } from "../replication.js";
10
10
  import { OntologyBackend } from "../runtime-contract.js";
11
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
11
12
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
12
13
  export declare const MCP_PATH = "/mcp";
13
14
  /** The MCP revisions the server speaks, newest first. */
@@ -18,6 +19,8 @@ export interface McpServerOptions {
18
19
  readonly connections: ConnectionTransportFactory;
19
20
  readonly replicate: ReplicationKick;
20
21
  readonly verifyToken: TokenVerifier;
22
+ /** The deployment's runtime tokens, acting as a member; `undefined` while that is disabled. */
23
+ readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
21
24
  /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
22
25
  readonly aggregates: Aggregates;
23
26
  }
@@ -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
  /**
@@ -415,7 +420,7 @@ export function mcpLayer(options) {
415
420
  operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
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
  }));