@segmentstream-cloud/ontology 0.10.0 → 0.12.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 (73) hide show
  1. package/README.md +6 -0
  2. package/dist/action.d.ts +19 -0
  3. package/dist/action.js +1 -0
  4. package/dist/analyze.d.ts +3 -1
  5. package/dist/analyze.js +81 -1
  6. package/dist/bin/migrate.js +3 -2
  7. package/dist/diagnostics.d.ts +3 -3
  8. package/dist/diagnostics.js +1 -0
  9. package/dist/index.d.ts +5 -3
  10. package/dist/index.js +4 -1
  11. package/dist/manifest.d.ts +48 -0
  12. package/dist/manifest.js +9 -0
  13. package/dist/migrate/apply.d.ts +8 -1
  14. package/dist/migrate/apply.js +54 -4
  15. package/dist/migrate/index.d.ts +1 -1
  16. package/dist/migrate/index.js +1 -1
  17. package/dist/migrate/model.d.ts +8 -0
  18. package/dist/migrate/plan.d.ts +6 -0
  19. package/dist/migrate/plan.js +10 -2
  20. package/dist/migrate/storage.d.ts +12 -0
  21. package/dist/migrate/storage.js +82 -0
  22. package/dist/names.d.ts +9 -1
  23. package/dist/names.js +11 -0
  24. package/dist/nitro.d.ts +13 -0
  25. package/dist/nitro.js +18 -0
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +89 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/generated-actions.d.ts +3 -0
  39. package/dist/runtime/generated-actions.js +3 -2
  40. package/dist/runtime/oauth.d.ts +9 -0
  41. package/dist/runtime/oauth.js +165 -0
  42. package/dist/runtime/pipeline.js +2 -1
  43. package/dist/runtime/principals.d.ts +5 -0
  44. package/dist/runtime/principals.js +21 -0
  45. package/dist/runtime/service-binding.js +10 -0
  46. package/dist/runtime/sql-runtime.js +2 -1
  47. package/dist/service/api.js +4 -10
  48. package/dist/service/authentication.d.ts +27 -1
  49. package/dist/service/authentication.js +48 -26
  50. package/dist/service/config.d.ts +5 -0
  51. package/dist/service/descriptions.d.ts +8 -0
  52. package/dist/service/descriptions.js +15 -0
  53. package/dist/service/errors.d.ts +5 -1
  54. package/dist/service/errors.js +6 -2
  55. package/dist/service/handlers.d.ts +3 -5
  56. package/dist/service/handlers.js +21 -204
  57. package/dist/service/mcp/server.d.ts +26 -0
  58. package/dist/service/mcp/server.js +370 -0
  59. package/dist/service/mcp/tools.d.ts +47 -0
  60. package/dist/service/mcp/tools.js +218 -0
  61. package/dist/service/oauth/protocol.d.ts +52 -0
  62. package/dist/service/oauth/protocol.js +110 -0
  63. package/dist/service/oauth/server.d.ts +27 -0
  64. package/dist/service/oauth/server.js +576 -0
  65. package/dist/service/operations.d.ts +115 -0
  66. package/dist/service/operations.js +228 -0
  67. package/dist/service/replication.d.ts +5 -0
  68. package/dist/service/replication.js +25 -0
  69. package/dist/service/runtime-contract.d.ts +90 -0
  70. package/dist/service/schemas.d.ts +8 -0
  71. package/dist/service/service.d.ts +3 -3
  72. package/dist/service/service.js +13 -6
  73. package/package.json +6 -1
@@ -4,6 +4,7 @@ import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/htt
4
4
  import { createRemoteJWKSet, decodeJwt, errors, jwtVerify, } from "jose";
5
5
  import { API_KEY_PREFIX } from "../runtime/principals.js";
6
6
  import { RequestId, crossOriginSession, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
7
+ import { ACCESS_TOKEN_PREFIX, principalOfAccessToken } from "./oauth/protocol.js";
7
8
  import { OntologyBackend } from "./runtime-contract.js";
8
9
  import { ErrorSchemas } from "./schemas.js";
9
10
  const CLOCK_TOLERANCE_SECONDS = 5;
@@ -274,13 +275,21 @@ function sessionMayAuthenticate(request) {
274
275
  return origin !== undefined && origin === requestOrigin(request);
275
276
  }
276
277
  /** Resolves a bearer credential to a principal. */
277
- export function authenticate(credential, verifyToken) {
278
+ export function authenticate(credential, verifyToken, options = {}) {
278
279
  return Effect.gen(function* () {
279
280
  const backend = yield* OntologyBackend;
280
281
  const token = Redacted.value(credential);
281
282
  if (token === "") {
282
283
  return { status: "missing" };
283
284
  }
285
+ if (token.startsWith(ACCESS_TOKEN_PREFIX)) {
286
+ const member = options.oauthResource === undefined || backend.oauth === undefined
287
+ ? undefined
288
+ : yield* principalOfAccessToken(backend.oauth, token, options.oauthResource);
289
+ return member === undefined
290
+ ? { status: "rejected" }
291
+ : { status: "authenticated", principal: member };
292
+ }
284
293
  if (token.startsWith(API_KEY_PREFIX)) {
285
294
  const service = yield* backend.resolveApiKey(credential);
286
295
  return service === undefined
@@ -323,6 +332,41 @@ function authenticateSession(request, exchangeSession, verifyToken) {
323
332
  }
324
333
  });
325
334
  }
335
+ /**
336
+ * Runs `effect` as the principal a bearer credential names, or answers the request as every route
337
+ * does: 401 without a valid credential, 403 for a person nobody invited or a session cookie sent
338
+ * from another origin, 503 while sign-in cannot be verified, and a sanitized 500 when
339
+ * authentication itself fails.
340
+ */
341
+ export function withPrincipal(credential, verifyToken, backend, effect, options = {}) {
342
+ return Effect.gen(function* () {
343
+ const requestId = yield* RequestId;
344
+ const outcome = yield* Effect.gen(function* () {
345
+ const fromSession = Redacted.value(credential) === "" && options.session !== undefined
346
+ ? yield* authenticateSession(options.session.request, options.session.exchange, verifyToken)
347
+ : undefined;
348
+ return fromSession ?? (yield* authenticate(credential, verifyToken, options));
349
+ }).pipe(Effect.provideService(OntologyBackend, backend), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
350
+ ? Effect.failCause(cause)
351
+ : Effect.logError("ontology.authentication.failed", cause).pipe(Effect.annotateLogs({ requestId }), Effect.as({ status: "failed" }))));
352
+ switch (outcome.status) {
353
+ case "authenticated":
354
+ return yield* Effect.provideService(effect, CurrentPrincipal, outcome.principal);
355
+ case "missing":
356
+ return toServerResponse(unauthorized(requestId, "A bearer token is required.", options.challenge?.(true)));
357
+ case "rejected":
358
+ return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked.", options.challenge?.(false)));
359
+ case "not_invited":
360
+ return toServerResponse(notInvited(requestId));
361
+ case "cross_origin":
362
+ return toServerResponse(crossOriginSession(requestId));
363
+ case "unavailable":
364
+ return yield* Effect.logWarning("ontology.authentication.unavailable").pipe(Effect.annotateLogs({ requestId }), Effect.as(toServerResponse(unavailable(requestId))));
365
+ case "failed":
366
+ return toServerResponse(internalError(requestId));
367
+ }
368
+ });
369
+ }
326
370
  /**
327
371
  * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
328
372
  * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
@@ -332,32 +376,10 @@ export function authenticationLayer(verifyToken, exchangeSession) {
332
376
  const backend = yield* OntologyBackend;
333
377
  return Authentication.of({
334
378
  bearer: (httpEffect, { credential }) => Effect.gen(function* () {
335
- const requestId = yield* RequestId;
336
379
  const request = yield* HttpServerRequest.HttpServerRequest;
337
- const outcome = yield* Effect.gen(function* () {
338
- const fromSession = Redacted.value(credential) === ""
339
- ? yield* authenticateSession(request, exchangeSession, verifyToken)
340
- : undefined;
341
- return fromSession ?? (yield* authenticate(credential, verifyToken));
342
- }).pipe(Effect.provideService(OntologyBackend, backend), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
343
- ? Effect.failCause(cause)
344
- : Effect.logError("ontology.authentication.failed", cause).pipe(Effect.annotateLogs({ requestId }), Effect.as({ status: "failed" }))));
345
- switch (outcome.status) {
346
- case "authenticated":
347
- return yield* Effect.provideService(httpEffect, CurrentPrincipal, outcome.principal);
348
- case "missing":
349
- return toServerResponse(unauthorized(requestId, "A bearer token is required."));
350
- case "rejected":
351
- return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
352
- case "not_invited":
353
- return toServerResponse(notInvited(requestId));
354
- case "cross_origin":
355
- return toServerResponse(crossOriginSession(requestId));
356
- case "unavailable":
357
- return yield* Effect.logWarning("ontology.authentication.unavailable").pipe(Effect.annotateLogs({ requestId }), Effect.as(toServerResponse(unavailable(requestId))));
358
- case "failed":
359
- return toServerResponse(internalError(requestId));
360
- }
380
+ return yield* withPrincipal(credential, verifyToken, backend, httpEffect, {
381
+ session: { request, exchange: exchangeSession },
382
+ });
361
383
  }),
362
384
  });
363
385
  }));
@@ -73,6 +73,11 @@ export interface OntologyServiceConfig extends OntologyEnvironment {
73
73
  * set, else SegmentStream's proxy with the request's Vercel OIDC token.
74
74
  */
75
75
  readonly connections?: ConnectionTransportFactory;
76
+ /**
77
+ * Serve the MCP server at `/mcp`. On by default: its tools reach exactly what the HTTP routes
78
+ * reach, with the same credentials.
79
+ */
80
+ readonly mcp?: boolean;
76
81
  }
77
82
  declare const OntologyServiceConfigError_base: Schema.Class<OntologyServiceConfigError, Schema.TaggedStruct<"OntologyServiceConfigError", {
78
83
  readonly message: Schema.String;
@@ -0,0 +1,8 @@
1
+ import type { PlannedAction } from "./plan.js";
2
+ /** `Requires lead:read.`, `Requires lead:create and lead:update.` */
3
+ export declare function requires(permissions: ReadonlyArray<string>): string;
4
+ /**
5
+ * What an action's route and its MCP tool say about it: its description, whether it is generated,
6
+ * the permissions it requires and the connections it calls.
7
+ */
8
+ export declare function actionDescription({ action, generated, permissions }: PlannedAction): string;
@@ -0,0 +1,15 @@
1
+ /** `Requires lead:read.`, `Requires lead:create and lead:update.` */
2
+ export function requires(permissions) {
3
+ return `Requires ${permissions.join(" and ")}.`;
4
+ }
5
+ /**
6
+ * What an action's route and its MCP tool say about it: its description, whether it is generated,
7
+ * the permissions it requires and the connections it calls.
8
+ */
9
+ export function actionDescription({ action, generated, permissions }) {
10
+ const origin = generated ? "Generated action." : "Action.";
11
+ const calls = action.connections.length > 0
12
+ ? ` Calls ${action.connections.map((handle) => handle.key).join(", ")} before its transaction.`
13
+ : "";
14
+ return `${action.description}\n\n${origin} ${requires(permissions)}${calls}`;
15
+ }
@@ -45,7 +45,11 @@ export declare function toServerResponse(response: ErrorResponse): HttpServerRes
45
45
  export declare function errorBodyForWire(body: ErrorBody): Readonly<Record<string, unknown>>;
46
46
  /** Issues of a schema failure, as `[{ path, message }]`. */
47
47
  export declare function schemaIssues(issue: SchemaIssue.Issue): Array<ValidationIssue>;
48
- export declare function unauthorized(requestId: string, detail: string): ErrorResponse;
48
+ /**
49
+ * `challenge` is the `WWW-Authenticate` value; the MCP server's names its OAuth metadata, which is
50
+ * how a client learns where members authorize it.
51
+ */
52
+ export declare function unauthorized(requestId: string, detail: string, challenge?: string): ErrorResponse;
49
53
  /**
50
54
  * The token is valid but the `invited` sign-in policy turns its holder away: nobody invited them.
51
55
  * The detail is written for the person, since the app shows it.
@@ -50,11 +50,15 @@ const REQUEST_PARTS = {
50
50
  function isJsonParseFailure(issue) {
51
51
  return (issue._tag === "InvalidValue" && issue.annotations?.message === "Expected a valid JSON body");
52
52
  }
53
- export function unauthorized(requestId, detail) {
53
+ /**
54
+ * `challenge` is the `WWW-Authenticate` value; the MCP server's names its OAuth metadata, which is
55
+ * how a client learns where members authorize it.
56
+ */
57
+ export function unauthorized(requestId, detail, challenge = 'Bearer realm="ontology"') {
54
58
  return {
55
59
  status: 401,
56
60
  body: { error: "unauthorized", detail, requestId },
57
- headers: { "www-authenticate": 'Bearer realm="ontology"' },
61
+ headers: { "www-authenticate": challenge },
58
62
  };
59
63
  }
60
64
  /**
@@ -1,17 +1,15 @@
1
1
  import { Layer } from "effect";
2
2
  import { type HttpApiGroup } from "effect/unstable/httpapi";
3
- import type { Query } from "../query.js";
4
3
  import { type BuiltApi } from "./api.js";
5
4
  import type { AfterCommitHook } from "./config.js";
6
- import { type ConnectionTransportFactory } from "./connections.js";
5
+ import type { ReplicationKick } from "./replication.js";
6
+ import type { ConnectionTransportFactory } from "./connections.js";
7
7
  import { type RateLimit } from "./rate-limit.js";
8
8
  import { OntologyBackend } from "./runtime-contract.js";
9
- /** The query the runtime receives: every key present only when set, and no empty `include`. */
10
- export declare function toQuery(decoded: Query): Query;
11
9
  /**
12
10
  * Sign-in checks one client may make per minute. A person checks once per sign-in, a few times at
13
11
  * most; the limit slows down anyone testing addresses to learn who the app's members are.
14
12
  */
15
13
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
16
14
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
17
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
15
+ export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
@@ -3,19 +3,13 @@ import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
4
  import { STORAGE_GROUP } from "../storage/api.js";
5
5
  import { Conflict } from "../errors.js";
6
- import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
7
- import { nameToWire } from "../authoring-names.js";
8
- import { principalFromWire } from "../authoring.js";
9
- import { toCamelCase } from "../names.js";
10
- import { PermissionDenied } from "../errors.js";
11
- import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
12
6
  import { EMAIL_PATTERN } from "../property.js";
13
7
  import { renderCatalog } from "../render.js";
14
8
  import { SERVICE_GROUP } from "./api.js";
15
9
  import { CurrentPrincipal } from "./authentication.js";
16
10
  import { catalogFor, graphOf } from "./catalog.js";
17
- import { OIDC_TOKEN_HEADER } from "./connections.js";
18
- import { InvalidActionInput, InvalidRequestBody, RequestId, SignInCheckLimited, schemaIssues, } from "./errors.js";
11
+ import { InvalidRequestBody, SignInCheckLimited, schemaIssues } from "./errors.js";
12
+ import { identityToken, makeOperations, requireRead, toQuery, } from "./operations.js";
19
13
  import { clientAddress, fixedWindowLimiter, } from "./rate-limit.js";
20
14
  import { OntologyBackend } from "./runtime-contract.js";
21
15
  import { ActionEnvelope } from "./schemas.js";
@@ -23,102 +17,6 @@ function dynamicGroup(api, identifier, build) {
23
17
  const group = HttpApiBuilder.group;
24
18
  return group(api, identifier, build);
25
19
  }
26
- /** The query the runtime receives: every key present only when set, and no empty `include`. */
27
- export function toQuery(decoded) {
28
- const { include, ...rest } = decoded;
29
- return include !== undefined && Object.keys(include).length > 0 ? { ...rest, include } : rest;
30
- }
31
- function page(result) {
32
- return { items: result.items, nextCursor: result.nextCursor ?? null };
33
- }
34
- function relationshipTargets(plan) {
35
- return new Map(plan.entities.map((planned) => [
36
- planned.name,
37
- new Map(planned.traversals.map((traversal) => [traversal.name, traversal.target])),
38
- ]));
39
- }
40
- /** Adds the entities a `where` looks into through relationship filters, at any combinator depth. */
41
- function filterReads(where, targets, reads) {
42
- for (const [key, condition] of Object.entries(where ?? {})) {
43
- if (key === "and" || key === "or") {
44
- for (const part of condition) {
45
- filterReads(part, targets, reads);
46
- }
47
- }
48
- else if (key === "not") {
49
- filterReads(condition, targets, reads);
50
- }
51
- else {
52
- const target = targets.get(key);
53
- if (target !== undefined) {
54
- reads.add(target);
55
- }
56
- }
57
- }
58
- }
59
- /**
60
- * The entities a query on `entity` reads: the entity, the entity of every relationship it includes,
61
- * and the entity of every relationship its filter looks into.
62
- */
63
- function queryReads(entity, query, targets) {
64
- const own = targets.get(entity) ?? new Map();
65
- const reads = new Set([entity]);
66
- for (const name of Object.keys(query.include ?? {})) {
67
- const target = own.get(name);
68
- if (target !== undefined) {
69
- reads.add(target);
70
- }
71
- }
72
- filterReads(query.where, own, reads);
73
- return [...reads];
74
- }
75
- /**
76
- * Requires `<entity>:read` on every entity a request reads. A missing one answers 403 for the whole
77
- * request, before anything is read: nothing is silently left out.
78
- */
79
- function requireRead(entities) {
80
- return Effect.gen(function* () {
81
- const principal = yield* CurrentPrincipal;
82
- const missing = missingPermission(entities.map((entity) => permissionOf(entity, READ_VERB)), principal);
83
- if (missing !== undefined) {
84
- return yield* new PermissionDenied({
85
- permission: missing,
86
- message: `Reading requires ${missing}, which the principal's roles do not grant.`,
87
- });
88
- }
89
- });
90
- }
91
- /**
92
- * Follows a relationship from one record with the query language: the record's own
93
- * `<relationship>_id` for a `rel.one`, and for everything to-many a relationship path from the
94
- * target back to the record (`lead.notes` is notes whose `lead` is the lead, `lead.tags` is tags
95
- * some of whose `leads` is the lead), combined with the caller's query and paged like any find.
96
- */
97
- function traverse(planned, traversal, request, targets) {
98
- return Effect.gen(function* () {
99
- const backend = yield* OntologyBackend;
100
- const { id } = request.params;
101
- const query = toQuery(request.query);
102
- yield* requireRead([planned.name, ...queryReads(traversal.target, query, targets)]);
103
- const source = yield* backend.get({ entity: planned.name, id });
104
- if (traversal.kind === "one") {
105
- const targetId = source[`${traversal.name}_id`];
106
- if (typeof targetId !== "string") {
107
- return { items: [], nextCursor: null };
108
- }
109
- return page(yield* backend.find({
110
- entity: traversal.target,
111
- query: { ...query, where: { id: { eq: targetId } }, limit: 1 },
112
- }));
113
- }
114
- const match = { id: { eq: id } };
115
- const related = traversal.kind === "inverseOfOne"
116
- ? { [traversal.opposite]: match }
117
- : { [traversal.opposite]: { some: match } };
118
- const where = query.where === undefined ? related : { and: [related, query.where] };
119
- return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
120
- });
121
- }
122
20
  function isJsonMediaType(contentType) {
123
21
  if (contentType === undefined || contentType.trim() === "") {
124
22
  return true;
@@ -143,68 +41,7 @@ function readEnvelope(request) {
143
41
  return yield* Schema.decodeUnknownEffect(ActionEnvelope)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error.issue) })));
144
42
  });
145
43
  }
146
- /**
147
- * The configured hook as `runAction` runs it after the commit. A failure is logged with the request
148
- * id here, so the action's caller still gets the committed result.
149
- */
150
- function afterCommitOf(hook, request, ontology) {
151
- return (committed) => {
152
- const definition = ontology.config.entities.find((entity) => nameToWire(entity.name) === committed.entity)?.definition;
153
- return Effect.tryPromise(async () => hook({
154
- entity: toCamelCase(committed.entity),
155
- action: toCamelCase(committed.action),
156
- principal: principalFromWire(committed.principal),
157
- input: request.generated ? recordFromWire(committed.input, definition) : committed.input,
158
- result: request.generated
159
- ? generatedResultFromWire(committed.result, committed.action, definition)
160
- : committed.result,
161
- edits: editsFromWire(committed.edits, ontology.config.entities),
162
- requestId: request.requestId,
163
- })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
164
- requestId: request.requestId,
165
- action: `${committed.entity}.${committed.action}`,
166
- }))));
167
- };
168
- }
169
- /** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
170
- function identityToken(request) {
171
- const header = request.headers[OIDC_TOKEN_HEADER];
172
- return typeof header === "string" && header !== "" ? header : process.env.VERCEL_OIDC_TOKEN;
173
- }
174
- function runAction(planned, { name, generated }, ontology, hook, connections) {
175
- return ({ request }) => Effect.gen(function* () {
176
- const backend = yield* OntologyBackend;
177
- const principal = yield* CurrentPrincipal;
178
- const requestId = yield* RequestId;
179
- const envelope = yield* readEnvelope(request);
180
- const validateOnly = envelope.options?.validateOnly === true;
181
- const outcome = yield* backend.runAction({
182
- entity: planned.name,
183
- action: name,
184
- input: envelope.input,
185
- principal,
186
- options: { ...envelope.options, validateOnly },
187
- requestId,
188
- connections: connections({
189
- identityToken: identityToken(request),
190
- audit: { action: `${planned.name}.${name}`, principalId: principal.id, requestId },
191
- }),
192
- ...(hook === undefined
193
- ? {}
194
- : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
195
- });
196
- const { issues } = outcome.validation;
197
- if (!outcome.validation.ok && !validateOnly) {
198
- return yield* new InvalidActionInput({ issues });
199
- }
200
- return {
201
- result: outcome.result ?? null,
202
- validation: { ok: outcome.validation.ok, issues },
203
- edits: outcome.edits,
204
- };
205
- });
206
- }
207
- function entityHandlers(built, planned, hooks, targets, connections) {
44
+ function entityHandlers(built, planned, operationsOf) {
208
45
  return dynamicGroup(built.api, planned.name, (handlers) => Effect.gen(function* () {
209
46
  if (planned.resolved.entity.definition.source !== undefined) {
210
47
  return handlers.handleRaw("sourceCollection", () => requireRead([planned.name]).pipe(Effect.andThen(() => HttpServerResponse.json({
@@ -213,14 +50,12 @@ function entityHandlers(built, planned, hooks, targets, connections) {
213
50
  }, { status: 404 }))));
214
51
  }
215
52
  const backend = yield* OntologyBackend;
216
- // Every read checks the caller's permissions first; the backend is called only afterwards.
217
- const find = (query) => requireRead(queryReads(planned.name, query, targets)).pipe(Effect.andThen(() => backend.find({ entity: planned.name, query })), Effect.map(page));
218
- const readOwn = requireRead([planned.name]);
53
+ const operations = operationsOf(backend);
219
54
  let registered = handlers
220
- .handle("find", (request) => find(toQuery(request.query)))
221
- .handle("query", (request) => find(toQuery(request.payload)))
222
- .handle("get", (request) => readOwn.pipe(Effect.andThen(() => backend.get({ entity: planned.name, id: request.params.id }))))
223
- .handle("history", (request) => readOwn.pipe(Effect.andThen(() => backend.history({ entity: planned.name, id: request.params.id, ...request.query })), Effect.map(page)));
55
+ .handle("find", (request) => operations.find(planned, toQuery(request.query)))
56
+ .handle("query", (request) => operations.find(planned, toQuery(request.payload)))
57
+ .handle("get", (request) => operations.get(planned, request.params.id))
58
+ .handle("history", (request) => operations.history(planned, request.params.id, request.query));
224
59
  for (const property of planned.resolved.properties.filter((property) => property.definition.type === "blob")) {
225
60
  registered = registered.handle(`blobs.${property.name}`, (request) => Effect.gen(function* () {
226
61
  const principal = yield* CurrentPrincipal;
@@ -234,37 +69,19 @@ function entityHandlers(built, planned, hooks, targets, connections) {
234
69
  }));
235
70
  }
236
71
  for (const traversal of planned.traversals) {
237
- registered = registered.handle(`related.${traversal.name}`, (request) => traverse(planned, traversal, request, targets).pipe(Effect.provideService(OntologyBackend, backend)));
72
+ registered = registered.handle(`related.${traversal.name}`, (request) => operations.traverse(planned, traversal, request.params.id, toQuery(request.query)));
238
73
  }
239
74
  for (const action of planned.actions) {
240
- const handler = runAction(planned, action, built.plan.ontology, hooks.get(`${planned.name}.${action.name}`), connections);
241
- registered = registered.handleRaw(`actions.${action.name}`, (request) => handler(request).pipe(Effect.provideService(OntologyBackend, backend)));
75
+ registered = registered.handleRaw(`actions.${action.name}`, ({ request }) => readEnvelope(request).pipe(Effect.flatMap((envelope) => operations.runAction(planned, action, {
76
+ input: envelope.input,
77
+ validateOnly: envelope.options?.validateOnly === true,
78
+ ...(envelope.options === undefined ? {} : { options: envelope.options }),
79
+ identityToken: identityToken(request),
80
+ }))));
242
81
  }
243
82
  return registered;
244
83
  }));
245
84
  }
246
- /**
247
- * The caller, as `GET /api/ontology/me` answers: their user record and the roles and permissions
248
- * authentication resolved. It needs no permission: anyone may learn who they are and what they may do.
249
- */
250
- function callerOf(backend) {
251
- return Effect.gen(function* () {
252
- const principal = yield* CurrentPrincipal;
253
- // Authentication resolved the principal from this user a moment ago; its absence is a defect.
254
- const user = yield* backend.get({ entity: "user", id: principal.id }).pipe(Effect.orDie);
255
- return {
256
- user: {
257
- id: principal.id,
258
- name: String(user.name),
259
- email: typeof user.email === "string" ? user.email : null,
260
- kind: principal.kind,
261
- active: user.active === true,
262
- },
263
- roles: principal.roles,
264
- permissions: principal.permissions,
265
- };
266
- });
267
- }
268
85
  /**
269
86
  * Sign-in checks one client may make per minute. A person checks once per sign-in, a few times at
270
87
  * most; the limit slows down anyone testing addresses to learn who the app's members are.
@@ -291,7 +108,7 @@ function checkSignIn(backend, limiter) {
291
108
  return yield* backend.checkSignIn(email);
292
109
  });
293
110
  }
294
- function serviceHandlers(built) {
111
+ function serviceHandlers(built, operationsOf) {
295
112
  const { manifest } = built.plan.ontology;
296
113
  const graph = graphOf(manifest);
297
114
  const signInLimiter = fixedWindowLimiter(SIGN_IN_CHECK_LIMIT);
@@ -309,7 +126,7 @@ function serviceHandlers(built) {
309
126
  return handlers
310
127
  .handle("catalog", (request) => Effect.map(permissionsFor(request.query.as), (permissions) => catalogFor(manifest, permissions)))
311
128
  .handle("graph", () => Effect.succeed(graph))
312
- .handle("me", () => callerOf(backend))
129
+ .handle("me", () => operationsOf(backend).caller)
313
130
  .handle("openapi", () => Effect.sync(() => {
314
131
  if (openApi === undefined) {
315
132
  openApi = OpenApi.fromApi(built.api);
@@ -361,14 +178,14 @@ function storageHandlers(built) {
361
178
  }));
362
179
  }
363
180
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
364
- export function handlersLayer(built, hooks, connections) {
365
- const targets = relationshipTargets(built.plan);
181
+ export function handlersLayer(built, hooks, connections, replicate) {
182
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
366
183
  const layers = [
367
- serviceHandlers(built),
184
+ serviceHandlers(built, operationsOf),
368
185
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
369
186
  ? [storageHandlers(built)]
370
187
  : []),
371
- ...built.plan.entities.map((planned) => entityHandlers(built, planned, hooks, targets, connections)),
188
+ ...built.plan.entities.map((planned) => entityHandlers(built, planned, operationsOf)),
372
189
  ];
373
190
  const [first, ...rest] = layers;
374
191
  return Layer.mergeAll(first, ...rest);
@@ -0,0 +1,26 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import type { BuiltApi } from "../api.js";
4
+ import { type TokenVerifier } from "../authentication.js";
5
+ import type { AfterCommitHook } from "../config.js";
6
+ import type { ConnectionTransportFactory } from "../connections.js";
7
+ import { RequestId } from "../errors.js";
8
+ import type { ReplicationKick } from "../replication.js";
9
+ import { OntologyBackend } from "../runtime-contract.js";
10
+ /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
11
+ export declare const MCP_PATH = "/mcp";
12
+ /** The MCP revisions the server speaks, newest first. */
13
+ export declare const MCP_PROTOCOL_VERSIONS: readonly ["2025-11-25", "2025-06-18"];
14
+ export interface McpServerOptions {
15
+ readonly built: BuiltApi;
16
+ readonly hooks: ReadonlyMap<string, AfterCommitHook>;
17
+ readonly connections: ConnectionTransportFactory;
18
+ readonly replicate: ReplicationKick;
19
+ readonly verifyToken: TokenVerifier;
20
+ }
21
+ /**
22
+ * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
23
+ * answer any request. Its tools run the same operations as the HTTP routes, behind the same
24
+ * authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
25
+ */
26
+ export declare function mcpLayer(options: McpServerOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;