@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
@@ -433,6 +433,87 @@ export const USER_ROLES_TABLE = "_user_roles";
433
433
  * secret only as a salted PBKDF2 hash.
434
434
  */
435
435
  export const API_KEYS_TABLE = "_api_keys";
436
+ /** OAuth clients that registered themselves to connect agents to the MCP server. */
437
+ export const OAUTH_CLIENTS_TABLE = "_oauth_clients";
438
+ /** Authorization requests waiting for a member's consent, for a few minutes. */
439
+ export const OAUTH_REQUESTS_TABLE = "_oauth_requests";
440
+ /** What a member allowed a client to do: revoking a grant stops its tokens. */
441
+ export const OAUTH_GRANTS_TABLE = "_oauth_grants";
442
+ /** Authorization codes, access tokens and refresh tokens, stored only as SHA-256 hashes. */
443
+ export const OAUTH_TOKENS_TABLE = "_oauth_tokens";
444
+ /** The OAuth tables, in the order they are created. */
445
+ export const OAUTH_TABLES = [
446
+ OAUTH_CLIENTS_TABLE,
447
+ OAUTH_REQUESTS_TABLE,
448
+ OAUTH_GRANTS_TABLE,
449
+ OAUTH_TOKENS_TABLE,
450
+ ];
451
+ /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
452
+ export const OAUTH_TABLES_DDL = [
453
+ [
454
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} (`,
455
+ ` "id" text NOT NULL,`,
456
+ ` "name" text NOT NULL,`,
457
+ ` "redirect_uris" jsonb NOT NULL,`,
458
+ ` "auth_method" text NOT NULL,`,
459
+ ` "secret_hash" text,`,
460
+ ` "client_uri" text,`,
461
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
462
+ ` CONSTRAINT "${OAUTH_CLIENTS_TABLE}_pkey" PRIMARY KEY ("id")`,
463
+ `)`,
464
+ ].join("\n"),
465
+ [
466
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_REQUESTS_TABLE)} (`,
467
+ ` "id" text NOT NULL,`,
468
+ ` "client_id" text NOT NULL,`,
469
+ ` "redirect_uri" text NOT NULL,`,
470
+ ` "state" text,`,
471
+ ` "code_challenge" text NOT NULL,`,
472
+ ` "scope" text NOT NULL,`,
473
+ ` "resource" text NOT NULL,`,
474
+ ` "expires_at" timestamptz NOT NULL,`,
475
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
476
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
477
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE`,
478
+ `)`,
479
+ ].join("\n"),
480
+ [
481
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_GRANTS_TABLE)} (`,
482
+ ` "id" uuid NOT NULL,`,
483
+ ` "client_id" text NOT NULL,`,
484
+ ` "user_id" uuid NOT NULL,`,
485
+ ` "scope" text NOT NULL,`,
486
+ ` "resource" text NOT NULL,`,
487
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
488
+ ` "revoked_at" timestamptz,`,
489
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
490
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
491
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE,`,
492
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
493
+ `REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
494
+ `)`,
495
+ ].join("\n"),
496
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_GRANTS_TABLE}_user_id_idx" ` +
497
+ `ON ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("user_id")`,
498
+ [
499
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_TOKENS_TABLE)} (`,
500
+ ` "hash" text NOT NULL,`,
501
+ ` "grant_id" uuid NOT NULL,`,
502
+ ` "kind" text NOT NULL,`,
503
+ ` "redirect_uri" text,`,
504
+ ` "code_challenge" text,`,
505
+ ` "expires_at" timestamptz NOT NULL,`,
506
+ ` "used_at" timestamptz,`,
507
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
508
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_pkey" PRIMARY KEY ("hash"),`,
509
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_kind_check" CHECK ("kind" IN ('code', 'access', 'refresh')),`,
510
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_grant_id_fkey" FOREIGN KEY ("grant_id") ` +
511
+ `REFERENCES ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("id") ON DELETE CASCADE`,
512
+ `)`,
513
+ ].join("\n"),
514
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
515
+ `ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
516
+ ];
436
517
  /**
437
518
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
438
519
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -464,6 +545,7 @@ export const RUNTIME_TABLES_DDL = [
464
545
  `)`,
465
546
  ].join("\n"),
466
547
  `CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
548
+ ...OAUTH_TABLES_DDL,
467
549
  ];
468
550
  export function columnDefinition(column) {
469
551
  const parts = [quoteIdentifier(column.name), column.type];
package/dist/names.d.ts CHANGED
@@ -21,7 +21,15 @@ export declare const HISTORY_SUFFIX = "_history";
21
21
  /** The combinators of the query language, which take the place of field names in a `where`. */
22
22
  export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
23
23
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
24
- export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me"];
24
+ export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
25
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
26
+ export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
27
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
28
+ export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me"];
29
+ /** The longest tool name that Claude and OpenAI accept. */
30
+ export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
31
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
32
+ export declare function mcpToolName(entity: string, operation: string): string;
25
33
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
26
34
  export declare const RESERVED_RELATIONSHIP_NAMES: readonly ["history"];
27
35
  export declare function isSystemProperty(name: string): boolean;
package/dist/names.js CHANGED
@@ -57,7 +57,18 @@ export const RESERVED_ENTITY_NAMES = [
57
57
  "docs",
58
58
  "mcp",
59
59
  "me",
60
+ "oauth",
60
61
  ];
62
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
63
+ export const MCP_READ_TOOLS = ["find", "get", "history"];
64
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
65
+ export const MCP_GLOBAL_TOOLS = ["describe", "me"];
66
+ /** The longest tool name that Claude and OpenAI accept. */
67
+ export const MCP_TOOL_NAME_MAX_LENGTH = 64;
68
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
69
+ export function mcpToolName(entity, operation) {
70
+ return `${entity}_${operation}`;
71
+ }
61
72
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
62
73
  export const RESERVED_RELATIONSHIP_NAMES = ["history"];
63
74
  export function isSystemProperty(name) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export declare const ONTOLOGY_SERVICE_PATHS: readonly ["/api/ontology", "/api/ontology/**", "/mcp", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-authorization-server"];
7
+ /** A Nitro route to the service's handler, loaded on its first request. */
8
+ export interface OntologyRoute {
9
+ readonly handler: string;
10
+ readonly lazy: true;
11
+ }
12
+ /** The Nitro routes that send every path of the ontology service to one handler. */
13
+ export declare function ontologyRoutes(handler: string): Record<string, OntologyRoute>;
package/dist/nitro.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export const ONTOLOGY_SERVICE_PATHS = [
7
+ "/api/ontology",
8
+ "/api/ontology/**",
9
+ "/mcp",
10
+ // The MCP server's OAuth metadata, on the app's origin.
11
+ "/.well-known/oauth-protected-resource",
12
+ "/.well-known/oauth-protected-resource/mcp",
13
+ "/.well-known/oauth-authorization-server",
14
+ ];
15
+ /** The Nitro routes that send every path of the ontology service to one handler. */
16
+ export function ontologyRoutes(handler) {
17
+ return Object.fromEntries(ONTOLOGY_SERVICE_PATHS.map((path) => [path, { handler, lazy: true }]));
18
+ }
@@ -1,3 +1,4 @@
1
+ import { Schema } from "effect";
1
2
  import { type AnyAction } from "../action.js";
2
3
  import type { JsonSchemaObject } from "../analyze.js";
3
4
  import type { Ontology } from "../ontology.js";
@@ -14,6 +15,8 @@ export declare function upsertActionName(property: string): string;
14
15
  * and services by the app or the CLI.
15
16
  */
16
17
  export declare function generatedActions(ontology: Ontology, entityName: string): GeneratedActions;
18
+ /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
19
+ export declare function actionInputJsonSchema(input: Schema.Top): JsonSchemaObject;
17
20
  /** A generated action, described as the manifest describes authored ones. */
18
21
  export interface GeneratedActionManifest {
19
22
  readonly name: string;
@@ -166,7 +166,8 @@ function buildGeneratedActions(ontology, entityName) {
166
166
  }
167
167
  return actions;
168
168
  }
169
- function inputJsonSchema(input) {
169
+ /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
170
+ export function actionInputJsonSchema(input) {
170
171
  const document = Schema.toJsonSchemaDocument(input, {
171
172
  includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
172
173
  onExcessProperty: "error",
@@ -182,6 +183,6 @@ export function generatedActionManifests(ontology, entityName) {
182
183
  name,
183
184
  description: generated.description,
184
185
  permissions: generatedActionPermissions(entityName, name),
185
- input: inputJsonSchema(generated.input),
186
+ input: actionInputJsonSchema(generated.input),
186
187
  }));
187
188
  }
@@ -0,0 +1,9 @@
1
+ import { SqlClient } from "effect/unstable/sql";
2
+ import type { Ontology } from "../ontology.js";
3
+ import type { OAuthStore } from "../service/runtime-contract.js";
4
+ /**
5
+ * The OAuth store on the runtime's OAuth tables, as `ontology_app`. Expiry is the database's
6
+ * `now()`; codes and refresh tokens are used with one `UPDATE … WHERE "used_at" IS NULL`, so two
7
+ * concurrent uses cannot both succeed.
8
+ */
9
+ export declare function sqlOAuthStore(ontology: Ontology, sql: SqlClient.SqlClient): OAuthStore;
@@ -0,0 +1,165 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql";
3
+ import { OAUTH_CLIENTS_TABLE, OAUTH_GRANTS_TABLE, OAUTH_REQUESTS_TABLE, OAUTH_TOKENS_TABLE, } from "../migrate/storage.js";
4
+ import { resolveMemberById } from "./principals.js";
5
+ const CLIENTS = `"${OAUTH_CLIENTS_TABLE}"`;
6
+ const REQUESTS = `"${OAUTH_REQUESTS_TABLE}"`;
7
+ const GRANTS = `"${OAUTH_GRANTS_TABLE}"`;
8
+ const TOKENS = `"${OAUTH_TOKENS_TABLE}"`;
9
+ const CLIENT_COLUMNS = `"id", "name", "redirect_uris"::text AS "redirect_uris", "auth_method", "secret_hash", ` +
10
+ `"client_uri"`;
11
+ /** A timestamp as epoch milliseconds, which every driver returns as a plain number. */
12
+ function epochMs(column) {
13
+ return `(extract(epoch from ${column}) * 1000)::double precision`;
14
+ }
15
+ const REQUEST_COLUMNS = `"id", "client_id", "redirect_uri", "state", "code_challenge", "scope", "resource", ` +
16
+ `${epochMs(`"expires_at"`)} AS "expires_at"`;
17
+ /** The grant's columns, read through the alias `g`. */
18
+ const GRANT_COLUMNS = `"g"."id"::text AS "grant_id", "g"."client_id" AS "client_id", ` +
19
+ `"g"."user_id"::text AS "user_id", "g"."scope" AS "scope", "g"."resource" AS "resource"`;
20
+ function text(value) {
21
+ return String(value);
22
+ }
23
+ function nullableText(value) {
24
+ return value === null || value === undefined ? null : String(value);
25
+ }
26
+ function date(value) {
27
+ return new Date(Number(value));
28
+ }
29
+ function clientOf(row) {
30
+ return {
31
+ id: text(row.id),
32
+ name: text(row.name),
33
+ redirectUris: JSON.parse(text(row.redirect_uris)),
34
+ authMethod: text(row.auth_method),
35
+ secretHash: nullableText(row.secret_hash),
36
+ clientUri: nullableText(row.client_uri),
37
+ };
38
+ }
39
+ function requestOf(row) {
40
+ return {
41
+ id: text(row.id),
42
+ clientId: text(row.client_id),
43
+ redirectUri: text(row.redirect_uri),
44
+ state: nullableText(row.state),
45
+ codeChallenge: text(row.code_challenge),
46
+ scope: text(row.scope),
47
+ resource: text(row.resource),
48
+ expiresAt: date(row.expires_at),
49
+ };
50
+ }
51
+ function grantOf(row) {
52
+ return {
53
+ id: text(row.grant_id),
54
+ clientId: text(row.client_id),
55
+ userId: text(row.user_id),
56
+ scope: text(row.scope),
57
+ resource: text(row.resource),
58
+ };
59
+ }
60
+ function tokenOf(row) {
61
+ return {
62
+ hash: text(row.hash),
63
+ grantId: text(row.grant_id),
64
+ kind: text(row.kind),
65
+ expiresAt: date(row.expires_at),
66
+ ...(row.redirect_uri === null ? {} : { redirectUri: text(row.redirect_uri) }),
67
+ ...(row.code_challenge === null ? {} : { codeChallenge: text(row.code_challenge) }),
68
+ };
69
+ }
70
+ const INSERT_TOKEN = `INSERT INTO ${TOKENS} ("hash", "grant_id", "kind", "redirect_uri", "code_challenge", ` +
71
+ `"expires_at") VALUES ($1, $2::uuid, $3, $4, $5, $6::timestamptz)`;
72
+ function tokenValues(token) {
73
+ return [
74
+ token.hash,
75
+ token.grantId,
76
+ token.kind,
77
+ token.redirectUri ?? null,
78
+ token.codeChallenge ?? null,
79
+ token.expiresAt.toISOString(),
80
+ ];
81
+ }
82
+ /**
83
+ * The OAuth store on the runtime's OAuth tables, as `ontology_app`. Expiry is the database's
84
+ * `now()`; codes and refresh tokens are used with one `UPDATE … WHERE "used_at" IS NULL`, so two
85
+ * concurrent uses cannot both succeed.
86
+ */
87
+ export function sqlOAuthStore(ontology, sql) {
88
+ const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
89
+ return {
90
+ saveClient: (client) => rows(`INSERT INTO ${CLIENTS} ("id", "name", "redirect_uris", "auth_method", "secret_hash", ` +
91
+ `"client_uri") VALUES ($1, $2, $3::jsonb, $4, $5, $6)`, [
92
+ client.id,
93
+ client.name,
94
+ JSON.stringify(client.redirectUris),
95
+ client.authMethod,
96
+ client.secretHash,
97
+ client.clientUri,
98
+ ]).pipe(Effect.asVoid),
99
+ findClient: (id) => rows(`SELECT ${CLIENT_COLUMNS} FROM ${CLIENTS} WHERE "id" = $1`, [id]).pipe(Effect.map(([row]) => (row === undefined ? undefined : clientOf(row)))),
100
+ saveRequest: (request) => sql
101
+ .withTransaction(Effect.gen(function* () {
102
+ yield* rows(`DELETE FROM ${REQUESTS} WHERE "expires_at" <= now()`);
103
+ yield* rows(`INSERT INTO ${REQUESTS} ("id", "client_id", "redirect_uri", "state", ` +
104
+ `"code_challenge", "scope", "resource", "expires_at") ` +
105
+ `VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz)`, [
106
+ request.id,
107
+ request.clientId,
108
+ request.redirectUri,
109
+ request.state,
110
+ request.codeChallenge,
111
+ request.scope,
112
+ request.resource,
113
+ request.expiresAt.toISOString(),
114
+ ]);
115
+ }))
116
+ .pipe(Effect.orDie),
117
+ findRequest: (id) => rows(`SELECT ${REQUEST_COLUMNS} FROM ${REQUESTS} WHERE "id" = $1 AND "expires_at" > now()`, [
118
+ id,
119
+ ]).pipe(Effect.map(([row]) => (row === undefined ? undefined : requestOf(row)))),
120
+ takeRequest: (id) => rows(`DELETE FROM ${REQUESTS} WHERE "id" = $1 AND "expires_at" > now() ` +
121
+ `RETURNING ${REQUEST_COLUMNS}`, [id]).pipe(Effect.map(([row]) => (row === undefined ? undefined : requestOf(row)))),
122
+ saveGrant: (grant, code) => sql
123
+ .withTransaction(Effect.gen(function* () {
124
+ yield* rows(`INSERT INTO ${GRANTS} ("id", "client_id", "user_id", "scope", "resource") ` +
125
+ `VALUES ($1::uuid, $2, $3::uuid, $4, $5)`, [grant.id, grant.clientId, grant.userId, grant.scope, grant.resource]);
126
+ yield* rows(INSERT_TOKEN, tokenValues(code));
127
+ }))
128
+ .pipe(Effect.orDie),
129
+ useToken: (hash, kind) => Effect.gen(function* () {
130
+ const [used] = yield* rows(`UPDATE ${TOKENS} AS "t" SET "used_at" = now() FROM ${GRANTS} AS "g" ` +
131
+ `WHERE "t"."hash" = $1 AND "t"."kind" = $2 AND "t"."used_at" IS NULL ` +
132
+ `AND "t"."expires_at" > now() AND "g"."id" = "t"."grant_id" ` +
133
+ `AND "g"."revoked_at" IS NULL ` +
134
+ `RETURNING "t"."hash" AS "hash", "t"."kind" AS "kind", ` +
135
+ `"t"."redirect_uri" AS "redirect_uri", "t"."code_challenge" AS "code_challenge", ` +
136
+ `${epochMs(`"t"."expires_at"`)} AS "expires_at", ${GRANT_COLUMNS}`, [hash, kind]);
137
+ if (used !== undefined) {
138
+ return { status: "valid", token: tokenOf(used), grant: grantOf(used) };
139
+ }
140
+ const [reused] = yield* rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
141
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" ` +
142
+ `WHERE "t"."hash" = $1 AND "t"."kind" = $2 AND "t"."used_at" IS NOT NULL ` +
143
+ `AND "t"."expires_at" > now() AND "g"."revoked_at" IS NULL`, [hash, kind]);
144
+ return reused === undefined
145
+ ? { status: "invalid" }
146
+ : { status: "reused", grant: grantOf(reused) };
147
+ }),
148
+ saveTokens: (tokens) => sql
149
+ .withTransaction(Effect.gen(function* () {
150
+ yield* rows(`DELETE FROM ${TOKENS} WHERE "expires_at" <= now()`);
151
+ for (const token of tokens) {
152
+ yield* rows(INSERT_TOKEN, tokenValues(token));
153
+ }
154
+ }))
155
+ .pipe(Effect.orDie),
156
+ findAccess: (hash) => rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
157
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" ` +
158
+ `WHERE "t"."hash" = $1 AND "t"."kind" = 'access' AND "t"."expires_at" > now() ` +
159
+ `AND "g"."revoked_at" IS NULL`, [hash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : grantOf(row)))),
160
+ findGrantOfToken: (hash) => rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
161
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" WHERE "t"."hash" = $1`, [hash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : grantOf(row)))),
162
+ revokeGrant: (id) => rows(`UPDATE ${GRANTS} SET "revoked_at" = now() WHERE "id" = $1::uuid AND "revoked_at" IS NULL`, [id]).pipe(Effect.asVoid),
163
+ memberPrincipal: (userId) => resolveMemberById(ontology, userId).pipe(Effect.provideService(SqlClient.SqlClient, sql)),
164
+ };
165
+ }
@@ -54,6 +54,11 @@ export declare function resolvePrincipalByIdentity(ontology: Ontology, claims: I
54
54
  * revoked keys and inactive users are rejected.
55
55
  */
56
56
  export declare function resolvePrincipalByApiKey(ontology: Ontology, key: string): Effect.Effect<Principal, PrincipalRejected, SqlClient.SqlClient>;
57
+ /**
58
+ * A member by user id, with the roles they hold now: whom an OAuth grant acts for. `undefined` for
59
+ * an unknown user, a service and an inactive member.
60
+ */
61
+ export declare function resolveMemberById(ontology: Ontology, userId: string): Effect.Effect<Principal | undefined, never, SqlClient.SqlClient>;
57
62
  export interface CreatedApiKey {
58
63
  readonly id: string;
59
64
  /** The key itself. It is not stored and cannot be shown again. */
@@ -241,6 +241,21 @@ export function resolvePrincipalByApiKey(ontology, key) {
241
241
  return yield* activePrincipal(ontology, sql, decodeUser({ id: row.id, kind: row.kind, active: row.active }));
242
242
  }).pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
243
243
  }
244
+ /**
245
+ * A member by user id, with the roles they hold now: whom an OAuth grant acts for. `undefined` for
246
+ * an unknown user, a service and an inactive member.
247
+ */
248
+ export function resolveMemberById(ontology, userId) {
249
+ return Effect.gen(function* () {
250
+ const sql = yield* SqlClient.SqlClient;
251
+ const [row] = yield* sql.unsafe(`SELECT "id"::text AS "id", "kind"::text AS "kind", "active" AS "active" ` +
252
+ `FROM "${USER_TABLE}" WHERE "id" = $1::uuid`, [userId]);
253
+ if (row === undefined || row.kind !== "member" || row.active !== true) {
254
+ return undefined;
255
+ }
256
+ return yield* rolesOf(ontology, sql, decodeUser(row));
257
+ }).pipe(Effect.orDie);
258
+ }
244
259
  /** Issues an API key for a user. */
245
260
  export function createApiKey(input) {
246
261
  return Effect.gen(function* () {
@@ -7,6 +7,7 @@ import { blobRuntime } from "../storage/sessions.js";
7
7
  import { readCatalog } from "../migrate/apply.js";
8
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
9
9
  import { generatedActions } from "./generated-actions.js";
10
+ import { sqlOAuthStore } from "./oauth.js";
10
11
  import { runAction } from "./pipeline.js";
11
12
  import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity } from "./principals.js";
12
13
  import { postgresTls } from "./postgres-tls.js";
@@ -59,6 +60,7 @@ export function backendLayer(ontology, blobStorage) {
59
60
  checkSignIn: (email) => withSql(checkSignIn(ontology, email)),
60
61
  resolveApiKey: (key) => withSql(resolvePrincipalByApiKey(ontology, Redacted.value(key))).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("api_key"))),
61
62
  appliedHash: readCatalog({ sql }).pipe(Effect.map((manifest) => manifest?.hash), Effect.orDie),
63
+ oauth: sqlOAuthStore(ontology, sql),
62
64
  });
63
65
  }));
64
66
  }
@@ -5,6 +5,7 @@ import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
5
5
  import { permissionOf, READ_VERB } from "../permissions.js";
6
6
  import { generatedActions } from "../runtime/generated-actions.js";
7
7
  import { Authentication } from "./authentication.js";
8
+ import { actionDescription, requires } from "./descriptions.js";
8
9
  import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
9
10
  import { planApi, } from "./plan.js";
10
11
  import { ActionResponse, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
@@ -58,10 +59,6 @@ const connectionActionErrors = [
58
59
  ErrorSchemas.connectionResponseInvalid,
59
60
  ErrorSchemas.connectionUnavailable,
60
61
  ];
61
- /** `Requires lead:read.`, `Requires lead:create and lead:update.` */
62
- function requires(permissions) {
63
- return `Requires ${permissions.join(" and ")}.`;
64
- }
65
62
  function readPermission(entity) {
66
63
  return permissionOf(entity, READ_VERB);
67
64
  }
@@ -75,17 +72,14 @@ function traversalEndpoint(planned, traversal, target, targetNames) {
75
72
  return documented(endpoint, `${label} ${traversal.phrase} ${reach} (${traversal.name})`, `${traversal.description}\n\n${requires(permissions)} Including a relationship requires ` +
76
73
  "reading its entity too.");
77
74
  }
78
- function actionEndpoint(planned, { name, action, generated, permissions }) {
75
+ function actionEndpoint(planned, plannedAction) {
76
+ const { name, action } = plannedAction;
79
77
  const endpoint = HttpApiEndpoint.post(`actions.${name}`, `${BASE_PATH}/${planned.name}/actions/${name}`, {
80
78
  payload: actionRequest(action.input),
81
79
  success: ActionResponse,
82
80
  error: action.connections.length > 0 ? connectionActionErrors : actionErrors,
83
81
  });
84
- const origin = generated ? "Generated action." : "Action.";
85
- const calls = action.connections.length > 0
86
- ? ` Calls ${action.connections.map((handle) => handle.key).join(", ")} before its transaction.`
87
- : "";
88
- return documented(endpoint, `${planned.name}.${name}`, `${action.description}\n\n${origin} ${requires(permissions)}${calls}`);
82
+ return documented(endpoint, `${planned.name}.${name}`, actionDescription(plannedAction));
89
83
  }
90
84
  function entityGroup(planned, schemas, names) {
91
85
  const own = schemas.get(planned.name);
@@ -1,4 +1,5 @@
1
1
  import { Context, Effect, Layer, Redacted } from "effect";
2
+ import { HttpServerRequest, type HttpServerResponse } from "effect/unstable/http";
2
3
  import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi";
3
4
  import { type JWTVerifyGetKey } from "jose";
4
5
  import type { Principal } from "../runtime.js";
@@ -117,8 +118,33 @@ type Authenticated = {
117
118
  } | {
118
119
  readonly status: "unavailable";
119
120
  };
121
+ /** Where a credential is presented. */
122
+ export interface CredentialOptions {
123
+ /**
124
+ * The resource OAuth access tokens must have been issued for: the MCP server's URL. Elsewhere,
125
+ * an access token is rejected, as it was issued for the MCP server only.
126
+ */
127
+ readonly oauthResource?: string;
128
+ }
120
129
  /** Resolves a bearer credential to a principal. */
121
- export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier): Effect.Effect<Authenticated, never, OntologyBackend>;
130
+ export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, options?: CredentialOptions): Effect.Effect<Authenticated, never, OntologyBackend>;
131
+ /** A browser's request, which may authenticate with the app's Neon Auth session cookie. */
132
+ export interface BrowserSession {
133
+ readonly request: HttpServerRequest.HttpServerRequest;
134
+ readonly exchange: SessionExchange | undefined;
135
+ }
136
+ /**
137
+ * Runs `effect` as the principal a bearer credential names, or answers the request as every route
138
+ * does: 401 without a valid credential, 403 for a person nobody invited or a session cookie sent
139
+ * from another origin, 503 while sign-in cannot be verified, and a sanitized 500 when
140
+ * authentication itself fails.
141
+ */
142
+ export declare function withPrincipal<E, R>(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, backend: OntologyBackend["Service"], effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, options?: CredentialOptions & {
143
+ /** The `WWW-Authenticate` challenge of a 401, by whether the credential was missing. */
144
+ readonly challenge?: (missing: boolean) => string;
145
+ /** Without a bearer token, the app's session cookie may authenticate this browser request. */
146
+ readonly session?: BrowserSession;
147
+ }): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Exclude<R, CurrentPrincipal> | RequestId>;
122
148
  /**
123
149
  * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
124
150
  * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
@@ -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;