@segmentstream-cloud/ontology 0.10.0 → 0.11.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 (56) hide show
  1. package/dist/action.d.ts +19 -0
  2. package/dist/action.js +1 -0
  3. package/dist/analyze.d.ts +3 -1
  4. package/dist/analyze.js +81 -1
  5. package/dist/bin/migrate.js +3 -2
  6. package/dist/diagnostics.d.ts +3 -3
  7. package/dist/diagnostics.js +1 -0
  8. package/dist/index.d.ts +5 -3
  9. package/dist/index.js +4 -1
  10. package/dist/manifest.d.ts +48 -0
  11. package/dist/manifest.js +9 -0
  12. package/dist/migrate/apply.d.ts +7 -0
  13. package/dist/migrate/apply.js +18 -1
  14. package/dist/migrate/index.d.ts +1 -1
  15. package/dist/migrate/index.js +1 -1
  16. package/dist/migrate/model.d.ts +8 -0
  17. package/dist/migrate/plan.d.ts +6 -0
  18. package/dist/migrate/plan.js +10 -2
  19. package/dist/migrate/storage.d.ts +12 -0
  20. package/dist/migrate/storage.js +82 -0
  21. package/dist/names.d.ts +9 -1
  22. package/dist/names.js +11 -0
  23. package/dist/nitro.d.ts +13 -0
  24. package/dist/nitro.js +18 -0
  25. package/dist/runtime/generated-actions.d.ts +3 -0
  26. package/dist/runtime/generated-actions.js +3 -2
  27. package/dist/runtime/oauth.d.ts +9 -0
  28. package/dist/runtime/oauth.js +165 -0
  29. package/dist/runtime/principals.d.ts +5 -0
  30. package/dist/runtime/principals.js +15 -0
  31. package/dist/runtime/service-binding.js +2 -0
  32. package/dist/service/api.js +4 -10
  33. package/dist/service/authentication.d.ts +27 -1
  34. package/dist/service/authentication.js +48 -26
  35. package/dist/service/config.d.ts +5 -0
  36. package/dist/service/descriptions.d.ts +8 -0
  37. package/dist/service/descriptions.js +15 -0
  38. package/dist/service/errors.d.ts +5 -1
  39. package/dist/service/errors.js +6 -2
  40. package/dist/service/handlers.d.ts +1 -4
  41. package/dist/service/handlers.js +20 -203
  42. package/dist/service/mcp/server.d.ts +24 -0
  43. package/dist/service/mcp/server.js +370 -0
  44. package/dist/service/mcp/tools.d.ts +47 -0
  45. package/dist/service/mcp/tools.js +218 -0
  46. package/dist/service/oauth/protocol.d.ts +52 -0
  47. package/dist/service/oauth/protocol.js +110 -0
  48. package/dist/service/oauth/server.d.ts +27 -0
  49. package/dist/service/oauth/server.js +576 -0
  50. package/dist/service/operations.d.ts +114 -0
  51. package/dist/service/operations.js +218 -0
  52. package/dist/service/runtime-contract.d.ts +89 -0
  53. package/dist/service/schemas.d.ts +8 -0
  54. package/dist/service/service.d.ts +3 -3
  55. package/dist/service/service.js +10 -5
  56. package/package.json +6 -1
@@ -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,13 +1,10 @@
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 { ConnectionTransportFactory } from "./connections.js";
7
6
  import { type RateLimit } from "./rate-limit.js";
8
7
  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
8
  /**
12
9
  * Sign-in checks one client may make per minute. A person checks once per sign-in, a few times at
13
10
  * most; the limit slows down anyone testing addresses to learn who the app's members are.
@@ -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);
@@ -362,13 +179,13 @@ function storageHandlers(built) {
362
179
  }
363
180
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
364
181
  export function handlersLayer(built, hooks, connections) {
365
- const targets = relationshipTargets(built.plan);
182
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections);
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,24 @@
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 { OntologyBackend } from "../runtime-contract.js";
9
+ /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
10
+ export declare const MCP_PATH = "/mcp";
11
+ /** The MCP revisions the server speaks, newest first. */
12
+ export declare const MCP_PROTOCOL_VERSIONS: readonly ["2025-11-25", "2025-06-18"];
13
+ export interface McpServerOptions {
14
+ readonly built: BuiltApi;
15
+ readonly hooks: ReadonlyMap<string, AfterCommitHook>;
16
+ readonly connections: ConnectionTransportFactory;
17
+ readonly verifyToken: TokenVerifier;
18
+ }
19
+ /**
20
+ * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
21
+ * answer any request. Its tools run the same operations as the HTTP routes, behind the same
22
+ * authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
23
+ */
24
+ export declare function mcpLayer(options: McpServerOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;