@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.
- package/dist/action.d.ts +19 -0
- package/dist/action.js +1 -0
- package/dist/analyze.d.ts +3 -1
- package/dist/analyze.js +97 -2
- package/dist/bin/migrate.js +3 -2
- package/dist/blob.d.ts +39 -0
- package/dist/blob.js +49 -0
- package/dist/diagnostics.d.ts +3 -3
- package/dist/diagnostics.js +1 -0
- package/dist/entity.d.ts +5 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +6 -1
- package/dist/manifest.d.ts +71 -7
- package/dist/manifest.js +18 -1
- package/dist/migrate/apply.d.ts +7 -0
- package/dist/migrate/apply.js +18 -1
- package/dist/migrate/index.d.ts +1 -1
- package/dist/migrate/index.js +1 -1
- package/dist/migrate/model.d.ts +15 -2
- package/dist/migrate/plan.d.ts +6 -0
- package/dist/migrate/plan.js +35 -3
- package/dist/migrate/storage.d.ts +14 -0
- package/dist/migrate/storage.js +95 -0
- package/dist/names.d.ts +9 -1
- package/dist/names.js +11 -0
- package/dist/nitro.d.ts +13 -0
- package/dist/nitro.js +18 -0
- package/dist/property.d.ts +10 -1
- package/dist/property.js +12 -1
- package/dist/query.js +6 -1
- package/dist/runtime/generated-actions.d.ts +3 -0
- package/dist/runtime/generated-actions.js +10 -5
- package/dist/runtime/history.d.ts +3 -0
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +165 -0
- package/dist/runtime/pipeline.d.ts +5 -1
- package/dist/runtime/pipeline.js +87 -3
- package/dist/runtime/principals.d.ts +5 -0
- package/dist/runtime/principals.js +15 -0
- package/dist/runtime/records.js +9 -0
- package/dist/runtime/service-binding.d.ts +2 -1
- package/dist/runtime/service-binding.js +15 -2
- package/dist/runtime/writes.js +17 -3
- package/dist/service/api.js +19 -10
- package/dist/service/authentication.d.ts +27 -1
- package/dist/service/authentication.js +48 -26
- package/dist/service/config.d.ts +8 -0
- package/dist/service/config.js +18 -0
- package/dist/service/descriptions.d.ts +8 -0
- package/dist/service/descriptions.js +15 -0
- package/dist/service/errors.d.ts +5 -1
- package/dist/service/errors.js +6 -2
- package/dist/service/handlers.d.ts +1 -4
- package/dist/service/handlers.js +59 -203
- package/dist/service/mcp/server.d.ts +24 -0
- package/dist/service/mcp/server.js +370 -0
- package/dist/service/mcp/tools.d.ts +47 -0
- package/dist/service/mcp/tools.js +218 -0
- package/dist/service/oauth/protocol.d.ts +52 -0
- package/dist/service/oauth/protocol.js +110 -0
- package/dist/service/oauth/server.d.ts +27 -0
- package/dist/service/oauth/server.js +576 -0
- package/dist/service/operations.d.ts +114 -0
- package/dist/service/operations.js +218 -0
- package/dist/service/runtime-contract.d.ts +98 -0
- package/dist/service/schemas.d.ts +18 -2
- package/dist/service/schemas.js +18 -2
- package/dist/service/service.d.ts +3 -3
- package/dist/service/service.js +27 -7
- package/dist/storage/api.d.ts +4 -0
- package/dist/storage/api.js +39 -0
- package/dist/storage/authorization.d.ts +8 -0
- package/dist/storage/authorization.js +30 -0
- package/dist/storage/maintenance-auth.d.ts +20 -0
- package/dist/storage/maintenance-auth.js +24 -0
- package/dist/storage/maintenance.d.ts +7 -0
- package/dist/storage/maintenance.js +50 -0
- package/dist/storage/neon.d.ts +3 -0
- package/dist/storage/neon.js +212 -0
- package/dist/storage/operator.d.ts +10 -0
- package/dist/storage/operator.js +30 -0
- package/dist/storage/provider.d.ts +80 -0
- package/dist/storage/provider.js +20 -0
- package/dist/storage/sessions.d.ts +87 -0
- package/dist/storage/sessions.js +341 -0
- package/dist/storage/tables.d.ts +2 -0
- package/dist/storage/tables.js +55 -0
- package/dist/storage/writes.d.ts +7 -0
- package/dist/storage/writes.js +88 -0
- package/package.json +8 -1
package/dist/runtime/pipeline.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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* () {
|
package/dist/runtime/records.js
CHANGED
|
@@ -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
|
};
|
package/dist/runtime/writes.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 };
|
package/dist/service/api.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
}));
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/service/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/service/errors.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|