@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.
@@ -153,6 +153,72 @@ 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
+ /**
184
+ * Where the app's agent keeps pairing codes and the identities they create: the runtime's agent
185
+ * tables in the workspace database. Codes are stored only as SHA-256 hashes. Expiry is checked
186
+ * against the database's clock; failures are defects.
187
+ */
188
+ export interface AgentStore {
189
+ /** Stores a pairing under its code's hash and forgets expired ones; false when the hash is taken. */
190
+ readonly savePairing: (pairing: AgentPairing & {
191
+ readonly codeHash: string;
192
+ }) => Effect.Effect<boolean>;
193
+ /** A pairing that has neither expired nor been approved. */
194
+ readonly findPairing: (codeHash: string) => Effect.Effect<AgentPairing | undefined>;
195
+ /**
196
+ * Consumes a live pairing, once, and pairs its account with the member. The account's previous
197
+ * identity and the member's previous identity on the same provider are revoked: each has one live
198
+ * identity per provider. `undefined` when the pairing expired or was already approved.
199
+ */
200
+ readonly approvePairing: (input: {
201
+ readonly codeHash: string;
202
+ readonly userId: string;
203
+ readonly identityId: string;
204
+ }) => Effect.Effect<AgentIdentity | undefined>;
205
+ /** The account's live identity, else its latest revoked one, else `undefined`. */
206
+ readonly findIdentity: (input: {
207
+ readonly provider: string;
208
+ readonly externalId: string;
209
+ }) => Effect.Effect<AgentIdentity | undefined>;
210
+ /** The member's live identities, oldest first. */
211
+ readonly listIdentities: (userId: string) => Effect.Effect<ReadonlyArray<AgentIdentity>>;
212
+ /** Revokes one of the member's live identities; false when they have no such identity. */
213
+ readonly revokeIdentity: (input: {
214
+ readonly id: string;
215
+ readonly userId: string;
216
+ }) => Effect.Effect<boolean>;
217
+ /** An active member by user id; `undefined` for an unknown user, a service or an inactive member. */
218
+ readonly member: (userId: string) => Effect.Effect<AgentMember | undefined>;
219
+ /** The member a runtime caller acts for: active, and with a live identity. */
220
+ readonly actingMember: (userId: string) => Effect.Effect<Principal | undefined>;
221
+ }
156
222
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
157
223
  readonly blobs?: BlobRuntime;
158
224
  readonly maintainBlobs?: Effect.Effect<{
@@ -192,6 +258,8 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
192
258
  readonly replicationEnabled?: Effect.Effect<boolean>;
193
259
  /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
194
260
  readonly oauth?: OAuthStore;
261
+ /** The agent tables, which pair channel accounts with members for the app's own agent. */
262
+ readonly agents?: AgentStore;
195
263
  }>;
196
264
  /**
197
265
  * The runtime as the HTTP service sees it, with its database and ontology already bound. Failures
@@ -0,0 +1,59 @@
1
+ import { Effect } from "effect";
2
+ import type { HttpServerRequest } from "effect/unstable/http";
3
+ import { type JWTVerifyGetKey } from "jose";
4
+ /**
5
+ * The header in which a runtime caller names the member it acts for: the member's user id. The
6
+ * service honours it only with a verified runtime token, so nobody else can claim a member with it.
7
+ */
8
+ export declare const ACT_AS_HEADER = "x-segmentstream-act-as";
9
+ /**
10
+ * Whom the service trusts as its runtime caller: Vercel OIDC tokens of the functions of this
11
+ * deployment's own project. The app's agent is a service of the same Vercel project as the ontology
12
+ * service, so `VERCEL_PROJECT_ID` pins exactly the deployment's own functions.
13
+ */
14
+ export interface RuntimeIdentityConfig {
15
+ /** The team's issuer: `https://oidc.vercel.com/<team slug>` in team issuer mode. */
16
+ readonly issuer: string;
17
+ /** The audience Vercel issues function tokens for: `https://vercel.com/<team slug>`. */
18
+ readonly audience: string;
19
+ /** The project the token must name, from `VERCEL_PROJECT_ID`. */
20
+ readonly projectId: string;
21
+ }
22
+ export type RuntimeVerification = {
23
+ readonly status: "valid";
24
+ }
25
+ /** The token claims the runtime issuer but fails verification; `reason` names what failed. */
26
+ | {
27
+ readonly status: "invalid";
28
+ readonly reason: string;
29
+ } | {
30
+ readonly status: "unavailable";
31
+ }
32
+ /** The token does not claim the runtime issuer: it is some other credential. */
33
+ | {
34
+ readonly status: "other";
35
+ };
36
+ /** Verifies a bearer token as the deployment's own runtime token. */
37
+ export type RuntimeTokenVerifier = (token: string) => Promise<RuntimeVerification>;
38
+ /** The issuer's key set, at `<issuer>/.well-known/jwks` as Vercel publishes it. */
39
+ export declare function runtimeKeySet(issuer: string): JWTVerifyGetKey;
40
+ /**
41
+ * Verifies the Vercel OIDC token of a function of this deployment's project, as backend-next
42
+ * verifies the ontology service's own: RS256 from the team's issuer, for the team's audience,
43
+ * unexpired, with `project_id` equal to the configured project and `environment` `production`.
44
+ * The issuer and the audience name the team, so no further owner check is needed. A token that
45
+ * claims another issuer is `other`, without a key-set fetch.
46
+ */
47
+ export declare function makeRuntimeTokenVerifier(config: RuntimeIdentityConfig, keySet: JWTVerifyGetKey): RuntimeTokenVerifier;
48
+ /** The runtime verifier on the issuer's remote key set, which jose caches between requests. */
49
+ export declare function runtimeTokenVerifier(config: RuntimeIdentityConfig): RuntimeTokenVerifier;
50
+ /** A runtime token that failed verification: the reason is logged, the token never. */
51
+ export declare function logRuntimeRejection(reason: string): Effect.Effect<void>;
52
+ /** How a request may authenticate as the runtime: the verifier and the member it names, if any. */
53
+ export interface RuntimeCredential {
54
+ readonly verify: RuntimeTokenVerifier;
55
+ /** The user id in `X-SegmentStream-Act-As`; `undefined` without the header. */
56
+ readonly actAs: string | undefined;
57
+ }
58
+ /** The request's runtime credential, or `undefined` while runtime authentication is disabled. */
59
+ export declare function runtimeCredential(verify: RuntimeTokenVerifier | undefined, request: HttpServerRequest.HttpServerRequest): RuntimeCredential | undefined;
@@ -0,0 +1,102 @@
1
+ import { Effect } from "effect";
2
+ import { createRemoteJWKSet, decodeJwt, errors, jwtVerify } from "jose";
3
+ /**
4
+ * The header in which a runtime caller names the member it acts for: the member's user id. The
5
+ * service honours it only with a verified runtime token, so nobody else can claim a member with it.
6
+ */
7
+ export const ACT_AS_HEADER = "x-segmentstream-act-as";
8
+ const CLOCK_TOLERANCE_SECONDS = 5;
9
+ const JWKS_TIMEOUT_MS = 5_000;
10
+ /** The environment that a Vercel function token must name: channels run only in production. */
11
+ const PRODUCTION = "production";
12
+ /** The issuer without a trailing slash, as Vercel writes it in `iss` and as its JWKS URL starts. */
13
+ function normalizedIssuer(issuer) {
14
+ let end = issuer.length;
15
+ while (end > 0 && issuer[end - 1] === "/") {
16
+ end -= 1;
17
+ }
18
+ return issuer.slice(0, end);
19
+ }
20
+ /** The issuer's key set, at `<issuer>/.well-known/jwks` as Vercel publishes it. */
21
+ export function runtimeKeySet(issuer) {
22
+ return createRemoteJWKSet(new URL(`${normalizedIssuer(issuer)}/.well-known/jwks`), {
23
+ timeoutDuration: JWKS_TIMEOUT_MS,
24
+ });
25
+ }
26
+ /** The key set could not be read: not the token's fault. Every other jose error is about the token. */
27
+ function keySetUnavailable(error) {
28
+ if (!(error instanceof errors.JOSEError)) {
29
+ return true;
30
+ }
31
+ return (error instanceof errors.JWKSTimeout ||
32
+ error instanceof errors.JWKSInvalid ||
33
+ error.code === errors.JOSEError.code);
34
+ }
35
+ /** What about a token failed, for the operator's log: a jose code or a claim name, never a value. */
36
+ function failureReason(error) {
37
+ if (error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired) {
38
+ return `claim:${error.claim}`;
39
+ }
40
+ return error instanceof errors.JOSEError ? error.code : "invalid";
41
+ }
42
+ function claimedIssuer(token) {
43
+ try {
44
+ const { iss } = decodeJwt(token);
45
+ return typeof iss === "string" ? iss : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Verifies the Vercel OIDC token of a function of this deployment's project, as backend-next
53
+ * verifies the ontology service's own: RS256 from the team's issuer, for the team's audience,
54
+ * unexpired, with `project_id` equal to the configured project and `environment` `production`.
55
+ * The issuer and the audience name the team, so no further owner check is needed. A token that
56
+ * claims another issuer is `other`, without a key-set fetch.
57
+ */
58
+ export function makeRuntimeTokenVerifier(config, keySet) {
59
+ const issuer = normalizedIssuer(config.issuer);
60
+ return async function verifyRuntimeToken(token) {
61
+ if (claimedIssuer(token) !== issuer) {
62
+ return { status: "other" };
63
+ }
64
+ try {
65
+ const { payload } = await jwtVerify(token, keySet, {
66
+ algorithms: ["RS256"],
67
+ issuer,
68
+ audience: config.audience,
69
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
70
+ requiredClaims: ["exp", "iat", "iss", "aud", "project_id", "environment"],
71
+ });
72
+ if (payload.project_id !== config.projectId) {
73
+ return { status: "invalid", reason: "claim:project_id" };
74
+ }
75
+ if (payload.environment !== PRODUCTION) {
76
+ return { status: "invalid", reason: "claim:environment" };
77
+ }
78
+ return { status: "valid" };
79
+ }
80
+ catch (error) {
81
+ return keySetUnavailable(error)
82
+ ? { status: "unavailable" }
83
+ : { status: "invalid", reason: failureReason(error) };
84
+ }
85
+ };
86
+ }
87
+ /** The runtime verifier on the issuer's remote key set, which jose caches between requests. */
88
+ export function runtimeTokenVerifier(config) {
89
+ return makeRuntimeTokenVerifier(config, runtimeKeySet(config.issuer));
90
+ }
91
+ /** A runtime token that failed verification: the reason is logged, the token never. */
92
+ export function logRuntimeRejection(reason) {
93
+ return Effect.logWarning("ontology.runtime_identity.rejected").pipe(Effect.annotateLogs({ reason }));
94
+ }
95
+ /** The request's runtime credential, or `undefined` while runtime authentication is disabled. */
96
+ export function runtimeCredential(verify, request) {
97
+ if (verify === undefined) {
98
+ return undefined;
99
+ }
100
+ const actAs = request.headers[ACT_AS_HEADER]?.trim();
101
+ return { verify, actAs: actAs === undefined || actAs === "" ? undefined : actAs };
102
+ }
@@ -13,8 +13,8 @@ export interface OntologyService {
13
13
  readonly dispose: () => Promise<void>;
14
14
  }
15
15
  /**
16
- * The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
17
- * behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
16
+ * The ontology service: the derived HttpApi, the MCP server at `/mcp` and the agent's pairing routes,
17
+ * wired to the runtime, behind Neon Auth, app-token, API-key and runtime-token authentication. Every response carries `x-ontology-hash`
18
18
  * and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
19
19
  */
20
20
  export declare function createOntologyService(ontology: Ontology, config: OntologyServiceConfig): OntologyService;
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { agentLayer } from "./agent/server.js";
7
8
  import { makeAggregates } from "./aggregate.js";
8
9
  import { platformAggregates } from "./aggregate-platform.js";
9
10
  import { ErrorResponsesLive, buildApi } from "./api.js";
@@ -17,6 +18,7 @@ import { deployedReplication } from "./replication.js";
17
18
  import { mcpLayer } from "./mcp/server.js";
18
19
  import { oauthLayer } from "./oauth/server.js";
19
20
  import { planApi } from "./plan.js";
21
+ import { runtimeTokenVerifier } from "./runtime-identity.js";
20
22
  /** The header every response carries: the hash of the manifest the service serves. */
21
23
  export const ONTOLOGY_HASH_HEADER = "x-ontology-hash";
22
24
  /** The header every response carries: the correlation id quoted in error bodies and logs. */
@@ -59,8 +61,8 @@ function decorate(response, requestId, hash) {
59
61
  });
60
62
  }
61
63
  /**
62
- * The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
63
- * behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
64
+ * The ontology service: the derived HttpApi, the MCP server at `/mcp` and the agent's pairing routes,
65
+ * wired to the runtime, behind Neon Auth, app-token, API-key and runtime-token authentication. Every response carries `x-ontology-hash`
64
66
  * and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
65
67
  */
66
68
  export function createOntologyService(ontology, config) {
@@ -76,6 +78,7 @@ export function createOntologyService(ontology, config) {
76
78
  const hooks = afterCommitHooks(plan, config.afterCommit);
77
79
  const verifyToken = tokenVerifier(config);
78
80
  const exchangeSession = config.neonAuth === undefined ? undefined : neonAuthSessionExchange(config.neonAuth);
81
+ const verifyRuntime = config.runtimeIdentity === undefined ? undefined : runtimeTokenVerifier(config.runtimeIdentity);
79
82
  const backend = runtime.connect({
80
83
  ontology,
81
84
  databaseUrl: config.databaseUrl,
@@ -91,14 +94,23 @@ export function createOntologyService(ontology, config) {
91
94
  transport: platformAggregates({ ...config, manifestHash: ontology.manifest.hash }),
92
95
  });
93
96
  const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections, replicate, aggregates)), Layer.provide([
94
- authenticationLayer(verifyToken, exchangeSession),
97
+ authenticationLayer(verifyToken, exchangeSession, verifyRuntime),
95
98
  ErrorResponsesLive,
96
99
  storageMaintenanceAuthenticationLayer(config.storageMaintenanceKey),
97
100
  ]));
98
101
  const mcp = config.mcp === false
99
102
  ? Layer.empty
100
- : Layer.mergeAll(mcpLayer({ built, hooks, connections, verifyToken, replicate, aggregates }), oauthLayer({ verifyToken }));
101
- const application = Layer.mergeAll(routes, mcp, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
103
+ : Layer.mergeAll(mcpLayer({
104
+ built,
105
+ hooks,
106
+ connections,
107
+ verifyToken,
108
+ verifyRuntime,
109
+ replicate,
110
+ aggregates,
111
+ }), oauthLayer({ verifyToken }));
112
+ const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
113
+ const application = Layer.mergeAll(routes, mcp, agent, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
102
114
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
103
115
  const hash = ontology.manifest.hash;
104
116
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",