@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.
- package/README.md +38 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/migrate/apply.js +2 -1
- package/dist/migrate/plan.js +21 -9
- package/dist/migrate/storage.d.ts +12 -0
- package/dist/migrate/storage.js +48 -0
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -0
- package/dist/runtime/agents.d.ts +9 -0
- package/dist/runtime/agents.js +145 -0
- package/dist/runtime/service-binding.js +2 -0
- package/dist/service/agent/protocol.d.ts +47 -0
- package/dist/service/agent/protocol.js +59 -0
- package/dist/service/agent/server.d.ts +27 -0
- package/dist/service/agent/server.js +235 -0
- package/dist/service/authentication.d.ts +16 -5
- package/dist/service/authentication.js +51 -6
- package/dist/service/config.d.ts +19 -2
- package/dist/service/config.js +44 -2
- package/dist/service/errors.d.ts +6 -0
- package/dist/service/errors.js +16 -0
- package/dist/service/mcp/server.d.ts +3 -0
- package/dist/service/mcp/server.js +14 -9
- package/dist/service/runtime-contract.d.ts +68 -0
- package/dist/service/runtime-identity.d.ts +59 -0
- package/dist/service/runtime-identity.js +102 -0
- package/dist/service/service.d.ts +2 -2
- package/dist/service/service.js +17 -5
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
28
|
-
* Failures answer the request directly, before its
|
|
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
|
-
*
|
|
18
|
-
* Failures answer the request directly, before its
|
|
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
|
});
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/service/config.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/service/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/service/errors.js
CHANGED
|
@@ -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,
|
|
395
|
-
? {}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
}));
|