@segmentstream-cloud/ontology 0.9.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 (90) 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 +97 -2
  5. package/dist/bin/migrate.js +3 -2
  6. package/dist/blob.d.ts +39 -0
  7. package/dist/blob.js +49 -0
  8. package/dist/diagnostics.d.ts +3 -3
  9. package/dist/diagnostics.js +1 -0
  10. package/dist/entity.d.ts +5 -3
  11. package/dist/index.d.ts +9 -4
  12. package/dist/index.js +6 -1
  13. package/dist/manifest.d.ts +71 -7
  14. package/dist/manifest.js +18 -1
  15. package/dist/migrate/apply.d.ts +7 -0
  16. package/dist/migrate/apply.js +18 -1
  17. package/dist/migrate/index.d.ts +1 -1
  18. package/dist/migrate/index.js +1 -1
  19. package/dist/migrate/model.d.ts +15 -2
  20. package/dist/migrate/plan.d.ts +6 -0
  21. package/dist/migrate/plan.js +35 -3
  22. package/dist/migrate/storage.d.ts +14 -0
  23. package/dist/migrate/storage.js +95 -0
  24. package/dist/names.d.ts +9 -1
  25. package/dist/names.js +11 -0
  26. package/dist/nitro.d.ts +13 -0
  27. package/dist/nitro.js +18 -0
  28. package/dist/property.d.ts +10 -1
  29. package/dist/property.js +12 -1
  30. package/dist/query.js +6 -1
  31. package/dist/runtime/generated-actions.d.ts +3 -0
  32. package/dist/runtime/generated-actions.js +10 -5
  33. package/dist/runtime/history.d.ts +3 -0
  34. package/dist/runtime/oauth.d.ts +9 -0
  35. package/dist/runtime/oauth.js +165 -0
  36. package/dist/runtime/pipeline.d.ts +5 -1
  37. package/dist/runtime/pipeline.js +87 -3
  38. package/dist/runtime/principals.d.ts +5 -0
  39. package/dist/runtime/principals.js +15 -0
  40. package/dist/runtime/records.js +9 -0
  41. package/dist/runtime/service-binding.d.ts +2 -1
  42. package/dist/runtime/service-binding.js +15 -2
  43. package/dist/runtime/writes.js +17 -3
  44. package/dist/service/api.js +19 -10
  45. package/dist/service/authentication.d.ts +27 -1
  46. package/dist/service/authentication.js +48 -26
  47. package/dist/service/config.d.ts +8 -0
  48. package/dist/service/config.js +18 -0
  49. package/dist/service/descriptions.d.ts +8 -0
  50. package/dist/service/descriptions.js +15 -0
  51. package/dist/service/errors.d.ts +5 -1
  52. package/dist/service/errors.js +6 -2
  53. package/dist/service/handlers.d.ts +1 -4
  54. package/dist/service/handlers.js +59 -203
  55. package/dist/service/mcp/server.d.ts +24 -0
  56. package/dist/service/mcp/server.js +370 -0
  57. package/dist/service/mcp/tools.d.ts +47 -0
  58. package/dist/service/mcp/tools.js +218 -0
  59. package/dist/service/oauth/protocol.d.ts +52 -0
  60. package/dist/service/oauth/protocol.js +110 -0
  61. package/dist/service/oauth/server.d.ts +27 -0
  62. package/dist/service/oauth/server.js +576 -0
  63. package/dist/service/operations.d.ts +114 -0
  64. package/dist/service/operations.js +218 -0
  65. package/dist/service/runtime-contract.d.ts +98 -0
  66. package/dist/service/schemas.d.ts +18 -2
  67. package/dist/service/schemas.js +18 -2
  68. package/dist/service/service.d.ts +3 -3
  69. package/dist/service/service.js +27 -7
  70. package/dist/storage/api.d.ts +4 -0
  71. package/dist/storage/api.js +39 -0
  72. package/dist/storage/authorization.d.ts +8 -0
  73. package/dist/storage/authorization.js +30 -0
  74. package/dist/storage/maintenance-auth.d.ts +20 -0
  75. package/dist/storage/maintenance-auth.js +24 -0
  76. package/dist/storage/maintenance.d.ts +7 -0
  77. package/dist/storage/maintenance.js +50 -0
  78. package/dist/storage/neon.d.ts +3 -0
  79. package/dist/storage/neon.js +212 -0
  80. package/dist/storage/operator.d.ts +10 -0
  81. package/dist/storage/operator.js +30 -0
  82. package/dist/storage/provider.d.ts +80 -0
  83. package/dist/storage/provider.js +20 -0
  84. package/dist/storage/sessions.d.ts +87 -0
  85. package/dist/storage/sessions.js +341 -0
  86. package/dist/storage/tables.d.ts +2 -0
  87. package/dist/storage/tables.js +55 -0
  88. package/dist/storage/writes.d.ts +7 -0
  89. package/dist/storage/writes.js +88 -0
  90. package/package.json +8 -1
@@ -1,7 +1,10 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { Cause, Duration, Effect, Option, Schema } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
4
  import { isSqlError } from "effect/unstable/sql/SqlError";
5
+ import { canonicalJson } from "../manifest.js";
6
+ import { Conflict } from "../errors.js";
7
+ import { authorizeBlobOperation } from "../storage/authorization.js";
5
8
  import { ConnectionError, ConnectionTransport, connectionContext } from "../connection.js";
6
9
  import { PermissionDenied } from "../errors.js";
7
10
  import { generatedActionPermissions, missingPermission } from "../permissions.js";
@@ -61,7 +64,8 @@ const NO_CONNECTIONS = {
61
64
  * 1. decode the input with the action's schema; issues are returned, not raised;
62
65
  * 2. check that the principal's permissions cover the action's (PermissionDenied);
63
66
  * 3. with `validateOnly`, stop;
64
- * 4. run `prepare`, when the action has one, without a transaction: reads and the declared
67
+ * 4. reauthorize and replay a committed blob action, if its idempotency key matches;
68
+ * otherwise run `prepare`, when the action has one, without a transaction: reads and the declared
65
69
  * connections only, under {@link PREPARE_TIMEOUT};
66
70
  * 5. without a handler, answer with what `prepare` returned;
67
71
  * 6. open one transaction and run the handler with what `prepare` returned and the runtime, which
@@ -107,6 +111,50 @@ export function runAction(ontology, request) {
107
111
  const actionId = randomUUID();
108
112
  const requestId = request.requestId ?? actionId;
109
113
  const context = { input: decoded.value, principal: request.principal, requestId };
114
+ const hasUploads = (value) => typeof value === "object" &&
115
+ value !== null &&
116
+ ("uploadId" in value || Object.values(value).some(hasUploads));
117
+ const key = request.options?.idempotencyKey;
118
+ const hasBlobProperties = ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"));
119
+ const consumesUpload = action.transactional && hasBlobProperties && (hasUploads(decoded.value) || key !== undefined);
120
+ if (consumesUpload && (!key || key.length > 128 || !request.branchId)) {
121
+ return yield* new Conflict({
122
+ entity: request.entity,
123
+ message: "Upload actions require an idempotency key.",
124
+ });
125
+ }
126
+ const fingerprint = consumesUpload
127
+ ? createHash("sha256")
128
+ .update(canonicalJson({
129
+ qualified,
130
+ manifestHash: ontology.manifest.hash,
131
+ branchId: request.branchId ?? null,
132
+ input: request.input,
133
+ createIntent: request.options?.createIntent ?? null,
134
+ }))
135
+ .digest("hex")
136
+ : undefined;
137
+ // A committed retry must not depend on preparation's external services being available.
138
+ // The handler transaction below still locks and rechecks the key for concurrent requests.
139
+ if (consumesUpload) {
140
+ const saved = yield* sql
141
+ .withTransaction(Effect.gen(function* () {
142
+ yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
143
+ const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results"
144
+ WHERE principal_id = $1::uuid AND key = $2`, [request.principal.id, key]);
145
+ if (saved && saved.fingerprint !== fingerprint) {
146
+ return yield* new Conflict({
147
+ entity: request.entity,
148
+ message: "Idempotency key was used for another action.",
149
+ });
150
+ }
151
+ return saved?.outcome;
152
+ }))
153
+ .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
154
+ if (saved) {
155
+ return saved;
156
+ }
157
+ }
110
158
  const prepared = action.prepare === undefined
111
159
  ? undefined
112
160
  : yield* prepare(ontology, sql, action, context, request.connections, qualified);
@@ -118,13 +166,49 @@ export function runAction(ontology, request) {
118
166
  action: qualified,
119
167
  actionId,
120
168
  principalId: request.principal.id,
169
+ ...(key ? { blobActionKey: key } : {}),
170
+ ...(request.branchId ? { branchId: request.branchId } : {}),
171
+ ...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
121
172
  onEdit: (edit) => edits.push(edit),
122
173
  });
123
174
  const handle = action.handler;
124
175
  const handler = Effect.suspend(() => handle({ ...context, prepared }));
176
+ let replay;
177
+ const transactionalHandler = Effect.gen(function* () {
178
+ if (consumesUpload) {
179
+ yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
180
+ yield* sql.unsafe(`INSERT INTO "_blob_action_results" (principal_id, key, fingerprint)
181
+ VALUES ($1::uuid, $2, $3) ON CONFLICT DO NOTHING`, [request.principal.id, key, fingerprint]);
182
+ const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results" WHERE principal_id = $1::uuid AND key = $2
183
+ FOR UPDATE`, [request.principal.id, key]);
184
+ if (saved.fingerprint !== fingerprint) {
185
+ return yield* new Conflict({
186
+ entity: request.entity,
187
+ message: "Idempotency key was used for another action.",
188
+ });
189
+ }
190
+ if (saved.outcome) {
191
+ replay = saved.outcome;
192
+ return replay.result;
193
+ }
194
+ }
195
+ const result = yield* handler.pipe(OntologyRuntime.provide(runtime));
196
+ if (consumesUpload) {
197
+ yield* sql.unsafe(`UPDATE "_blob_action_results" SET outcome = $1::jsonb
198
+ WHERE principal_id = $2::uuid AND key = $3`, [
199
+ JSON.stringify({ result: result ?? null, validation: VALID, edits }),
200
+ request.principal.id,
201
+ key,
202
+ ]);
203
+ }
204
+ return result;
205
+ });
125
206
  const result = yield* sql
126
- .withTransaction(handler.pipe(OntologyRuntime.provide(runtime)))
207
+ .withTransaction(transactionalHandler)
127
208
  .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
209
+ if (replay) {
210
+ return replay;
211
+ }
128
212
  const committed = {
129
213
  entity: request.entity,
130
214
  action: request.action,
@@ -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* () {
@@ -12,6 +12,11 @@ import { validate } from "./validation.js";
12
12
  */
13
13
  function readExpression(field, column) {
14
14
  switch (field.storage) {
15
+ case "blob":
16
+ return (`(SELECT json_build_object('id', b.id::text, 'filename', b.filename, ` +
17
+ `'contentType', b.content_type, 'size', b.size, 'createdAt', ` +
18
+ `to_char(b.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))::text ` +
19
+ `FROM "_blobs" b WHERE b.id = ${column})`);
15
20
  case "timestamp":
16
21
  return `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
17
22
  case "date":
@@ -42,6 +47,7 @@ function readValue(field, value) {
42
47
  switch (field.storage) {
43
48
  case "integer":
44
49
  return Number(value);
50
+ case "blob":
45
51
  case "json":
46
52
  return typeof value === "string" ? JSON.parse(value) : value;
47
53
  case "timestamp":
@@ -76,6 +82,9 @@ export function bindValue(field, value) {
76
82
  if (value === null) {
77
83
  return null;
78
84
  }
85
+ if (field.storage === "blob") {
86
+ return typeof value === "string" ? value : value.id;
87
+ }
79
88
  return field.storage === "json" ? JSON.stringify(value) : value;
80
89
  }
81
90
  function propertyIssue(field, value) {
@@ -1,10 +1,11 @@
1
1
  import { Layer, Redacted } from "effect";
2
2
  import { SqlClient } from "effect/unstable/sql";
3
3
  import type { SqlError } from "effect/unstable/sql/SqlError";
4
+ import type { BlobStorageBinding } from "../storage/provider.js";
4
5
  import type { Ontology } from "../ontology.js";
5
6
  import { OntologyBackend, type OntologyServiceRuntime } from "../service/runtime-contract.js";
6
7
  /** The service's backend on the runtime, over the SQL client in context. */
7
- export declare function backendLayer(ontology: Ontology): Layer.Layer<OntologyBackend, never, SqlClient.SqlClient>;
8
+ export declare function backendLayer(ontology: Ontology, blobStorage?: Redacted.Redacted<BlobStorageBinding>): Layer.Layer<OntologyBackend, never, SqlClient.SqlClient>;
8
9
  /**
9
10
  * `@effect/sql-pg` on the pooled `ontology_app` connection string, with the TLS server name a
10
11
  * Neon endpoint needs.
@@ -1,9 +1,13 @@
1
1
  import * as PgClient from "@effect/sql-pg/PgClient";
2
2
  import { Effect, Layer, Redacted } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
+ import { neonBlobProvider } from "../storage/neon.js";
5
+ import { maintainBlobStorage } from "../storage/maintenance.js";
6
+ import { blobRuntime } from "../storage/sessions.js";
4
7
  import { readCatalog } from "../migrate/apply.js";
5
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
6
9
  import { generatedActions } from "./generated-actions.js";
10
+ import { sqlOAuthStore } from "./oauth.js";
7
11
  import { runAction } from "./pipeline.js";
8
12
  import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity } from "./principals.js";
9
13
  import { postgresTls } from "./postgres-tls.js";
@@ -18,18 +22,26 @@ function rejectedAs(operation) {
18
22
  return (rejection) => Effect.logWarning("ontology.principal.rejected").pipe(Effect.annotateLogs({ operation, reason: rejection.reason }), Effect.as(undefined));
19
23
  }
20
24
  /** The service's backend on the runtime, over the SQL client in context. */
21
- export function backendLayer(ontology) {
25
+ export function backendLayer(ontology, blobStorage) {
22
26
  return Layer.effect(OntologyBackend, Effect.gen(function* () {
23
27
  const sql = yield* SqlClient.SqlClient;
24
28
  const runtime = makeOntologyRuntime(ontology, sql);
25
29
  const withSql = (effect) => Effect.provideService(effect, SqlClient.SqlClient, sql);
30
+ const binding = blobStorage ? Redacted.value(blobStorage) : undefined;
31
+ const provider = binding
32
+ ? yield* Effect.acquireRelease(Effect.sync(() => neonBlobProvider(binding)), (provider) => Effect.sync(() => provider.close?.()))
33
+ : undefined;
34
+ const blobs = provider ? blobRuntime(ontology, sql, provider) : undefined;
26
35
  return OntologyBackend.of({
36
+ ...(blobs ? { blobs } : {}),
37
+ ...(provider ? { maintainBlobs: maintainBlobStorage(sql, provider) } : {}),
27
38
  runAction: (input) => withSql(runAction(ontology, {
28
39
  entity: input.entity,
29
40
  action: input.action,
30
41
  input: input.input,
31
42
  principal: input.principal,
32
43
  options: input.options,
44
+ ...(binding ? { branchId: binding.branchId } : {}),
33
45
  afterCommit: input.afterCommit === undefined ? [] : [input.afterCommit],
34
46
  ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
35
47
  ...(input.connections === undefined ? {} : { connections: input.connections }),
@@ -48,6 +60,7 @@ export function backendLayer(ontology) {
48
60
  checkSignIn: (email) => withSql(checkSignIn(ontology, email)),
49
61
  resolveApiKey: (key) => withSql(resolvePrincipalByApiKey(ontology, Redacted.value(key))).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("api_key"))),
50
62
  appliedHash: readCatalog({ sql }).pipe(Effect.map((manifest) => manifest?.hash), Effect.orDie),
63
+ oauth: sqlOAuthStore(ontology, sql),
51
64
  });
52
65
  }));
53
66
  }
@@ -66,5 +79,5 @@ export function postgresLayer(databaseUrl) {
66
79
  /** The default runtime of `createOntologyService`: the SQL runtime on the workspace database. */
67
80
  export const sqlServiceRuntime = {
68
81
  generatedActions: (entity, ontology) => generatedActions(ontology, entity.name),
69
- connect: ({ ontology, databaseUrl }) => backendLayer(ontology).pipe(Layer.provide(postgresLayer(databaseUrl))),
82
+ connect: ({ ontology, databaseUrl, blobStorage }) => backendLayer(ontology, blobStorage).pipe(Layer.provide(postgresLayer(databaseUrl))),
70
83
  };
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { resolveBlobWrites } from "../storage/writes.js";
2
3
  import { Conflict } from "../errors.js";
3
4
  import { quoteIdentifier } from "../migrate/storage.js";
4
5
  import { InvalidValues } from "./errors.js";
@@ -21,7 +22,9 @@ function requireReferences(context, references) {
21
22
  }
22
23
  function insertRecord(context, entity, values, onConflict) {
23
24
  return Effect.gen(function* () {
24
- const write = yield* prepareWrite(entity, values, "create");
25
+ const recordId = uuidv7();
26
+ const resolved = yield* resolveBlobWrites(context, entity, recordId, values, true);
27
+ const write = yield* prepareWrite(entity, resolved, "create");
25
28
  yield* requireReferences(context, write.references);
26
29
  const params = new Params();
27
30
  const principal = params.add(context.scope.principalId, "uuid");
@@ -34,7 +37,7 @@ function insertRecord(context, entity, values, onConflict) {
34
37
  ...write.columns.map(({ field }) => quoteIdentifier(field.column)),
35
38
  ];
36
39
  const placeholders = [
37
- params.add(uuidv7(), "uuid"),
40
+ params.add(recordId, "uuid"),
38
41
  "now()",
39
42
  "now()",
40
43
  principal,
@@ -64,11 +67,12 @@ export function createRecord(context, entity, values) {
64
67
  }
65
68
  export function updateRecord(context, entity, id, values) {
66
69
  return Effect.gen(function* () {
67
- const write = yield* prepareWrite(entity, values, "update");
68
70
  const before = yield* loadRecord(context, entity, id, "update");
69
71
  if (before === undefined) {
70
72
  return yield* notFound(entity, id);
71
73
  }
74
+ const resolved = yield* resolveBlobWrites(context, entity, id, values, false);
75
+ const write = yield* prepareWrite(entity, resolved, "update");
72
76
  yield* requireReferences(context, write.references);
73
77
  let after = before;
74
78
  if (write.columns.length > 0) {
@@ -121,6 +125,16 @@ export function upsertRecord(context, entity, property, values) {
121
125
  issues: [{ path: [property], message: `${property} is required` }],
122
126
  }));
123
127
  }
128
+ // A file ticket for an existing record must not be consumed by a speculative INSERT.
129
+ if (entity.fields.some((candidate) => candidate.storage === "blob" && values[candidate.name] != null)) {
130
+ const lookup = new Params();
131
+ const [existing] = yield* context.run(`SELECT id::text AS id FROM ${quoteIdentifier(entity.table)}
132
+ WHERE ${quoteIdentifier(field.column)} = ${lookup.add(bindValue(field, key), field.sqlType)} FOR UPDATE`, lookup);
133
+ if (existing) {
134
+ const record = yield* updateRecord(context, entity, existing.id, values);
135
+ return { record, created: false };
136
+ }
137
+ }
124
138
  const created = yield* insertRecord(context, entity, values, ` ON CONFLICT (${quoteIdentifier(field.column)}) DO NOTHING`);
125
139
  if (created !== undefined) {
126
140
  return { record: created, created: true };
@@ -1,8 +1,11 @@
1
1
  import { Effect, Layer, Schema } from "effect";
2
2
  import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi, } from "effect/unstable/httpapi";
3
+ import { storageGroup } from "../storage/api.js";
4
+ import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
3
5
  import { permissionOf, READ_VERB } from "../permissions.js";
4
6
  import { generatedActions } from "../runtime/generated-actions.js";
5
7
  import { Authentication } from "./authentication.js";
8
+ import { actionDescription, requires } from "./descriptions.js";
6
9
  import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
7
10
  import { planApi, } from "./plan.js";
8
11
  import { ActionResponse, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
@@ -56,10 +59,6 @@ const connectionActionErrors = [
56
59
  ErrorSchemas.connectionResponseInvalid,
57
60
  ErrorSchemas.connectionUnavailable,
58
61
  ];
59
- /** `Requires lead:read.`, `Requires lead:create and lead:update.` */
60
- function requires(permissions) {
61
- return `Requires ${permissions.join(" and ")}.`;
62
- }
63
62
  function readPermission(entity) {
64
63
  return permissionOf(entity, READ_VERB);
65
64
  }
@@ -73,17 +72,14 @@ function traversalEndpoint(planned, traversal, target, targetNames) {
73
72
  return documented(endpoint, `${label} ${traversal.phrase} ${reach} (${traversal.name})`, `${traversal.description}\n\n${requires(permissions)} Including a relationship requires ` +
74
73
  "reading its entity too.");
75
74
  }
76
- function actionEndpoint(planned, { name, action, generated, permissions }) {
75
+ function actionEndpoint(planned, plannedAction) {
76
+ const { name, action } = plannedAction;
77
77
  const endpoint = HttpApiEndpoint.post(`actions.${name}`, `${BASE_PATH}/${planned.name}/actions/${name}`, {
78
78
  payload: actionRequest(action.input),
79
79
  success: ActionResponse,
80
80
  error: action.connections.length > 0 ? connectionActionErrors : actionErrors,
81
81
  });
82
- const origin = generated ? "Generated action." : "Action.";
83
- const calls = action.connections.length > 0
84
- ? ` Calls ${action.connections.map((handle) => handle.key).join(", ")} before its transaction.`
85
- : "";
86
- return documented(endpoint, `${planned.name}.${name}`, `${action.description}\n\n${origin} ${requires(permissions)}${calls}`);
82
+ return documented(endpoint, `${planned.name}.${name}`, actionDescription(plannedAction));
87
83
  }
88
84
  function entityGroup(planned, schemas, names) {
89
85
  const own = schemas.get(planned.name);
@@ -121,6 +117,14 @@ function entityGroup(planned, schemas, names) {
121
117
  error: recordErrors,
122
118
  }), `History of a ${definition.label.toLowerCase()}`, `What each action changed on the record, newest first. ${read}`),
123
119
  ...planned.traversals.map((traversal) => traversalEndpoint(planned, traversal, schemas.get(traversal.target), names.get(traversal.target) ?? { label: traversal.target, plural: traversal.target })),
120
+ ...planned.resolved.properties
121
+ .filter((property) => property.definition.type === "blob")
122
+ .map((property) => documented(HttpApiEndpoint.post(`blobs.${property.name}`, `${base}/:id/blobs/${property.name}/download`, {
123
+ params: { id: IdParam },
124
+ payload: DownloadRequest,
125
+ success: TransferGrant,
126
+ error: recordErrors,
127
+ }), `Download ${property.name}`, `Requires ${planned.name}:read. Historical revisions must belong to this record and field.`)),
124
128
  ...planned.actions.map((action) => actionEndpoint(planned, action)),
125
129
  ];
126
130
  const [first, ...rest] = endpoints.map(authenticated);
@@ -172,6 +176,11 @@ export function buildApi(plan) {
172
176
  ]));
173
177
  const groups = [
174
178
  serviceGroup(),
179
+ ...(plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
180
+ ? [
181
+ storageGroup(authenticated, actionErrors, (endpoint) => endpoint.middleware(ErrorResponses)),
182
+ ]
183
+ : []),
175
184
  ...plan.entities.map((planned) => entityGroup(planned, schemas, names)),
176
185
  ];
177
186
  const api = HttpApi.make("ontology")
@@ -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
  }));
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import type { Principal } from "../runtime.js";
3
4
  import { type ConnectionTransportFactory, type LocalConnectionContext } from "./connections.js";
4
5
  import type { OntologyServiceRuntime } from "./runtime-contract.js";
@@ -29,6 +30,8 @@ export interface NeonAuthEnvironment {
29
30
  export interface OntologyEnvironment {
30
31
  /** The pooled connection string of the `ontology_app` role. */
31
32
  readonly databaseUrl: Redacted.Redacted<string>;
33
+ readonly blobStorage?: Redacted.Redacted<BlobStorageBinding>;
34
+ readonly storageMaintenanceKey?: Redacted.Redacted<string>;
32
35
  /** The control plane: it issues app tokens and serves their JWKS. */
33
36
  readonly segmentstreamUrl: string;
34
37
  /** App tokens must be issued for this application: `aud: app:<application id>`. */
@@ -70,6 +73,11 @@ export interface OntologyServiceConfig extends OntologyEnvironment {
70
73
  * set, else SegmentStream's proxy with the request's Vercel OIDC token.
71
74
  */
72
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;
73
81
  }
74
82
  declare const OntologyServiceConfigError_base: Schema.Class<OntologyServiceConfigError, Schema.TaggedStruct<"OntologyServiceConfigError", {
75
83
  readonly message: Schema.String;
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import { LocalConnectionContextError, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./connections.js";
3
4
  /** The environment of the workspace deployment's ontology service. */
4
5
  export const ENVIRONMENT_VARIABLES = {
@@ -83,12 +84,29 @@ export function configFromEnv(environment = process.env) {
83
84
  }
84
85
  const neonAuth = neonAuthFromEnv(read);
85
86
  const localConnections = localConnectionsFromEnv(environment);
87
+ let blobStorage;
88
+ if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
89
+ try {
90
+ blobStorage = Redacted.make(Schema.decodeUnknownSync(BlobStorageBinding)(JSON.parse(read("SEGMENTSTREAM_ONTOLOGY_STORAGE"))));
91
+ }
92
+ catch {
93
+ throw new OntologyServiceConfigError({
94
+ message: "SEGMENTSTREAM_ONTOLOGY_STORAGE is invalid.",
95
+ });
96
+ }
97
+ }
86
98
  return {
87
99
  databaseUrl: checkDatabaseUrl(ENVIRONMENT_VARIABLES.databaseUrl, read(ENVIRONMENT_VARIABLES.databaseUrl)),
88
100
  segmentstreamUrl: normalizeUrl(ENVIRONMENT_VARIABLES.segmentstreamUrl, read(ENVIRONMENT_VARIABLES.segmentstreamUrl)),
89
101
  applicationId: read(ENVIRONMENT_VARIABLES.applicationId),
90
102
  workspaceId: read(ENVIRONMENT_VARIABLES.workspaceId),
91
103
  organizationId: read(ENVIRONMENT_VARIABLES.organizationId),
104
+ ...(blobStorage === undefined ? {} : { blobStorage }),
105
+ ...(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")
106
+ ? {
107
+ storageMaintenanceKey: Redacted.make(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")),
108
+ }
109
+ : {}),
92
110
  ...(neonAuth === undefined ? {} : { neonAuth }),
93
111
  ...(localConnections === undefined ? {} : { localConnections }),
94
112
  };
@@ -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.