@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.
- package/dist/action.d.ts +19 -0
- package/dist/action.js +1 -0
- package/dist/analyze.d.ts +3 -1
- package/dist/analyze.js +81 -1
- package/dist/bin/migrate.js +3 -2
- package/dist/diagnostics.d.ts +3 -3
- package/dist/diagnostics.js +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -1
- package/dist/manifest.d.ts +48 -0
- package/dist/manifest.js +9 -0
- 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 +8 -0
- package/dist/migrate/plan.d.ts +6 -0
- package/dist/migrate/plan.js +10 -2
- package/dist/migrate/storage.d.ts +12 -0
- package/dist/migrate/storage.js +82 -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/runtime/generated-actions.d.ts +3 -0
- package/dist/runtime/generated-actions.js +3 -2
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +165 -0
- package/dist/runtime/principals.d.ts +5 -0
- package/dist/runtime/principals.js +15 -0
- package/dist/runtime/service-binding.js +2 -0
- package/dist/service/api.js +4 -10
- package/dist/service/authentication.d.ts +27 -1
- package/dist/service/authentication.js +48 -26
- package/dist/service/config.d.ts +5 -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 +20 -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 +89 -0
- package/dist/service/schemas.d.ts +8 -0
- package/dist/service/service.d.ts +3 -3
- package/dist/service/service.js +10 -5
- package/package.json +6 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import { PermissionDenied } from "../errors.js";
|
|
4
|
+
import type { Query, QueryResult } from "../query.js";
|
|
5
|
+
import { CurrentPrincipal } from "./authentication.js";
|
|
6
|
+
import type { AfterCommitHook } from "./config.js";
|
|
7
|
+
import { type ConnectionTransportFactory } from "./connections.js";
|
|
8
|
+
import { InvalidActionInput, RequestId } from "./errors.js";
|
|
9
|
+
import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
|
|
10
|
+
import type { OntologyBackend } from "./runtime-contract.js";
|
|
11
|
+
/**
|
|
12
|
+
* What the service does for a caller, whatever the transport: the reads and actions of every entity
|
|
13
|
+
* and the caller's own account. The HTTP routes and the MCP tools both call these, so a permission
|
|
14
|
+
* check, a validation or a history row never depends on how the request arrived. Each requires the
|
|
15
|
+
* caller, as {@link CurrentPrincipal}, and the request id.
|
|
16
|
+
*/
|
|
17
|
+
/** The query the runtime receives: every key present only when set, and no empty `include`. */
|
|
18
|
+
export declare function toQuery(decoded: Query): Query;
|
|
19
|
+
/**
|
|
20
|
+
* Requires `<entity>:read` on every entity a request reads. A missing one fails the whole request,
|
|
21
|
+
* before anything is read: nothing is silently left out.
|
|
22
|
+
*/
|
|
23
|
+
export declare function requireRead(entities: ReadonlyArray<string>): Effect.Effect<undefined, PermissionDenied, CurrentPrincipal>;
|
|
24
|
+
/** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
|
|
25
|
+
export declare function identityToken(request: HttpServerRequest.HttpServerRequest): string | undefined;
|
|
26
|
+
/** An action call, as a transport decoded it. */
|
|
27
|
+
export interface ActionCall {
|
|
28
|
+
/** The input as the caller sent it; the runtime validates it against the action's input schema. */
|
|
29
|
+
readonly input: unknown;
|
|
30
|
+
readonly validateOnly: boolean;
|
|
31
|
+
/** The request's other action options: the idempotency key and create intent of an upload. */
|
|
32
|
+
readonly options?: {
|
|
33
|
+
readonly idempotencyKey?: string;
|
|
34
|
+
readonly createIntent?: string;
|
|
35
|
+
};
|
|
36
|
+
/** The deployment's OIDC token, which authorizes the action's connection calls. */
|
|
37
|
+
readonly identityToken: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** What an action answers: `{ result, validation, edits }`. */
|
|
40
|
+
export interface ActionAnswer {
|
|
41
|
+
readonly result: unknown;
|
|
42
|
+
readonly validation: {
|
|
43
|
+
readonly ok: boolean;
|
|
44
|
+
readonly issues: ReadonlyArray<unknown>;
|
|
45
|
+
};
|
|
46
|
+
readonly edits: ReadonlyArray<unknown>;
|
|
47
|
+
}
|
|
48
|
+
/** The caller, as `GET /api/ontology/me` answers. */
|
|
49
|
+
export interface Caller {
|
|
50
|
+
readonly user: {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly email: string | null;
|
|
54
|
+
readonly kind: string;
|
|
55
|
+
readonly active: boolean;
|
|
56
|
+
};
|
|
57
|
+
readonly roles: ReadonlyArray<string>;
|
|
58
|
+
readonly permissions: ReadonlyArray<string>;
|
|
59
|
+
}
|
|
60
|
+
type Backend = OntologyBackend["Service"];
|
|
61
|
+
/** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
|
|
62
|
+
export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): {
|
|
63
|
+
/** A page of records. Requires `read` on the entity and on every entity the query reaches. */
|
|
64
|
+
find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
|
|
65
|
+
/** One record. Requires `read` on the entity. */
|
|
66
|
+
get: (planned: PlannedEntity, id: string) => Effect.Effect<Readonly<Record<string, unknown>>, PermissionDenied | import("../errors.js").NotFound, CurrentPrincipal>;
|
|
67
|
+
/** A page of a record's history, newest first. Requires `read` on the entity. */
|
|
68
|
+
history: (planned: PlannedEntity, id: string, params: {
|
|
69
|
+
readonly after?: string;
|
|
70
|
+
readonly limit?: number;
|
|
71
|
+
}) => Effect.Effect<QueryResult<{
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly recordId: string;
|
|
74
|
+
readonly action: string;
|
|
75
|
+
readonly actionId: string;
|
|
76
|
+
readonly kind: "create" | "update" | "delete" | "link" | "unlink";
|
|
77
|
+
readonly principalId: string | null;
|
|
78
|
+
readonly at: string;
|
|
79
|
+
readonly changes: {
|
|
80
|
+
readonly [x: string]: {
|
|
81
|
+
readonly before: import("effect/Schema").Json;
|
|
82
|
+
readonly after: import("effect/Schema").Json;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
}>, PermissionDenied | import("../errors.js").NotFound | import("../index.js").InvalidQuery, CurrentPrincipal>;
|
|
86
|
+
/** The records a relationship reaches from one record, as a page of the target entity. */
|
|
87
|
+
traverse: (planned: PlannedEntity, traversal: Traversal, id: string, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../errors.js").NotFound | import("../index.js").InvalidQuery, CurrentPrincipal>;
|
|
88
|
+
/** Runs an action as the caller, with the action's after-commit hook and connections. */
|
|
89
|
+
runAction: (planned: PlannedEntity, { name, generated }: PlannedAction, call: ActionCall) => Effect.Effect<{
|
|
90
|
+
result: {} | null;
|
|
91
|
+
validation: {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
issues: readonly {
|
|
94
|
+
readonly path: readonly (string | number)[];
|
|
95
|
+
readonly message: string;
|
|
96
|
+
}[];
|
|
97
|
+
};
|
|
98
|
+
edits: readonly {
|
|
99
|
+
readonly entity: string;
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly kind: "create" | "update" | "delete" | "link" | "unlink";
|
|
102
|
+
readonly changes: {
|
|
103
|
+
readonly [x: string]: {
|
|
104
|
+
readonly before: import("effect/Schema").Json;
|
|
105
|
+
readonly after: import("effect/Schema").Json;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}[];
|
|
109
|
+
}, import("../connection.js").ConnectionError | import("../errors.js").ActionRejected | PermissionDenied | import("../errors.js").Conflict | import("../errors.js").NotFound | InvalidActionInput, RequestId | CurrentPrincipal>;
|
|
110
|
+
/** The caller's user, roles and permissions. It needs no permission. */
|
|
111
|
+
caller: Effect.Effect<Caller, never, CurrentPrincipal>;
|
|
112
|
+
};
|
|
113
|
+
export type Operations = ReturnType<typeof makeOperations>;
|
|
114
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
|
|
3
|
+
import { nameToWire } from "../authoring-names.js";
|
|
4
|
+
import { principalFromWire } from "../authoring.js";
|
|
5
|
+
import { PermissionDenied } from "../errors.js";
|
|
6
|
+
import { toCamelCase } from "../names.js";
|
|
7
|
+
import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
|
|
8
|
+
import { CurrentPrincipal } from "./authentication.js";
|
|
9
|
+
import { OIDC_TOKEN_HEADER } from "./connections.js";
|
|
10
|
+
import { InvalidActionInput, RequestId } from "./errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* What the service does for a caller, whatever the transport: the reads and actions of every entity
|
|
13
|
+
* and the caller's own account. The HTTP routes and the MCP tools both call these, so a permission
|
|
14
|
+
* check, a validation or a history row never depends on how the request arrived. Each requires the
|
|
15
|
+
* caller, as {@link CurrentPrincipal}, and the request id.
|
|
16
|
+
*/
|
|
17
|
+
/** The query the runtime receives: every key present only when set, and no empty `include`. */
|
|
18
|
+
export function toQuery(decoded) {
|
|
19
|
+
const { include, ...rest } = decoded;
|
|
20
|
+
return include !== undefined && Object.keys(include).length > 0 ? { ...rest, include } : rest;
|
|
21
|
+
}
|
|
22
|
+
function page(result) {
|
|
23
|
+
return { items: result.items, nextCursor: result.nextCursor ?? null };
|
|
24
|
+
}
|
|
25
|
+
function relationshipTargets(plan) {
|
|
26
|
+
return new Map(plan.entities.map((planned) => [
|
|
27
|
+
planned.name,
|
|
28
|
+
new Map(planned.traversals.map((traversal) => [traversal.name, traversal.target])),
|
|
29
|
+
]));
|
|
30
|
+
}
|
|
31
|
+
/** Adds the entities a `where` looks into through relationship filters, at any combinator depth. */
|
|
32
|
+
function filterReads(where, targets, reads) {
|
|
33
|
+
for (const [key, condition] of Object.entries(where ?? {})) {
|
|
34
|
+
if (key === "and" || key === "or") {
|
|
35
|
+
for (const part of condition) {
|
|
36
|
+
filterReads(part, targets, reads);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (key === "not") {
|
|
40
|
+
filterReads(condition, targets, reads);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const target = targets.get(key);
|
|
44
|
+
if (target !== undefined) {
|
|
45
|
+
reads.add(target);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The entities a query on `entity` reads: the entity, the entity of every relationship it includes,
|
|
52
|
+
* and the entity of every relationship its filter looks into.
|
|
53
|
+
*/
|
|
54
|
+
function queryReads(entity, query, targets) {
|
|
55
|
+
const own = targets.get(entity) ?? new Map();
|
|
56
|
+
const reads = new Set([entity]);
|
|
57
|
+
for (const name of Object.keys(query.include ?? {})) {
|
|
58
|
+
const target = own.get(name);
|
|
59
|
+
if (target !== undefined) {
|
|
60
|
+
reads.add(target);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
filterReads(query.where, own, reads);
|
|
64
|
+
return [...reads];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Requires `<entity>:read` on every entity a request reads. A missing one fails the whole request,
|
|
68
|
+
* before anything is read: nothing is silently left out.
|
|
69
|
+
*/
|
|
70
|
+
export function requireRead(entities) {
|
|
71
|
+
return Effect.gen(function* () {
|
|
72
|
+
const principal = yield* CurrentPrincipal;
|
|
73
|
+
const missing = missingPermission(entities.map((entity) => permissionOf(entity, READ_VERB)), principal);
|
|
74
|
+
if (missing !== undefined) {
|
|
75
|
+
return yield* new PermissionDenied({
|
|
76
|
+
permission: missing,
|
|
77
|
+
message: `Reading requires ${missing}, which the principal's roles do not grant.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The configured hook as `runAction` runs it after the commit. A failure is logged with the request
|
|
84
|
+
* id here, so the action's caller still gets the committed result.
|
|
85
|
+
*/
|
|
86
|
+
function afterCommitOf(hook, request, ontology) {
|
|
87
|
+
return (committed) => {
|
|
88
|
+
const definition = ontology.config.entities.find((entity) => nameToWire(entity.name) === committed.entity)?.definition;
|
|
89
|
+
return Effect.tryPromise(async () => hook({
|
|
90
|
+
entity: toCamelCase(committed.entity),
|
|
91
|
+
action: toCamelCase(committed.action),
|
|
92
|
+
principal: principalFromWire(committed.principal),
|
|
93
|
+
input: request.generated ? recordFromWire(committed.input, definition) : committed.input,
|
|
94
|
+
result: request.generated
|
|
95
|
+
? generatedResultFromWire(committed.result, committed.action, definition)
|
|
96
|
+
: committed.result,
|
|
97
|
+
edits: editsFromWire(committed.edits, ontology.config.entities),
|
|
98
|
+
requestId: request.requestId,
|
|
99
|
+
})).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
|
|
100
|
+
requestId: request.requestId,
|
|
101
|
+
action: `${committed.entity}.${committed.action}`,
|
|
102
|
+
}))));
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
|
|
106
|
+
export function identityToken(request) {
|
|
107
|
+
const header = request.headers[OIDC_TOKEN_HEADER];
|
|
108
|
+
return typeof header === "string" && header !== "" ? header : process.env.VERCEL_OIDC_TOKEN;
|
|
109
|
+
}
|
|
110
|
+
function findOf(backend, targets, entity, query) {
|
|
111
|
+
// Every read checks the caller's permissions first; the backend is called only afterwards.
|
|
112
|
+
return requireRead(queryReads(entity, query, targets)).pipe(Effect.andThen(() => backend.find({ entity, query })), Effect.map(page));
|
|
113
|
+
}
|
|
114
|
+
function getOf(backend, entity, id) {
|
|
115
|
+
return requireRead([entity]).pipe(Effect.andThen(() => backend.get({ entity, id })));
|
|
116
|
+
}
|
|
117
|
+
function historyOf(backend, entity, id, params) {
|
|
118
|
+
return requireRead([entity]).pipe(Effect.andThen(() => backend.history({ entity, id, ...params })), Effect.map(page));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Follows a relationship from one record with the query language: the record's own
|
|
122
|
+
* `<relationship>_id` for a `rel.one`, and for everything to-many a relationship path from the
|
|
123
|
+
* target back to the record (`lead.notes` is notes whose `lead` is the lead, `lead.tags` is tags
|
|
124
|
+
* some of whose `leads` is the lead), combined with the caller's query and paged like any find.
|
|
125
|
+
*/
|
|
126
|
+
function traverseOf(backend, targets, planned, traversal, id, query) {
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
yield* requireRead([planned.name, ...queryReads(traversal.target, query, targets)]);
|
|
129
|
+
const source = yield* backend.get({ entity: planned.name, id });
|
|
130
|
+
if (traversal.kind === "one") {
|
|
131
|
+
const targetId = source[`${traversal.name}_id`];
|
|
132
|
+
if (typeof targetId !== "string") {
|
|
133
|
+
return { items: [], nextCursor: null };
|
|
134
|
+
}
|
|
135
|
+
return page(yield* backend.find({
|
|
136
|
+
entity: traversal.target,
|
|
137
|
+
query: { ...query, where: { id: { eq: targetId } }, limit: 1 },
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
const match = { id: { eq: id } };
|
|
141
|
+
const related = traversal.kind === "inverseOfOne"
|
|
142
|
+
? { [traversal.opposite]: match }
|
|
143
|
+
: { [traversal.opposite]: { some: match } };
|
|
144
|
+
const where = query.where === undefined ? related : { and: [related, query.where] };
|
|
145
|
+
return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function runActionOf(backend, ontology, hooks, connections) {
|
|
149
|
+
return (planned, { name, generated }, call) => Effect.gen(function* () {
|
|
150
|
+
const principal = yield* CurrentPrincipal;
|
|
151
|
+
const requestId = yield* RequestId;
|
|
152
|
+
const hook = hooks.get(`${planned.name}.${name}`);
|
|
153
|
+
const outcome = yield* backend.runAction({
|
|
154
|
+
entity: planned.name,
|
|
155
|
+
action: name,
|
|
156
|
+
input: call.input,
|
|
157
|
+
principal,
|
|
158
|
+
options: { ...call.options, validateOnly: call.validateOnly },
|
|
159
|
+
requestId,
|
|
160
|
+
connections: connections({
|
|
161
|
+
identityToken: call.identityToken,
|
|
162
|
+
audit: { action: `${planned.name}.${name}`, principalId: principal.id, requestId },
|
|
163
|
+
}),
|
|
164
|
+
...(hook === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
|
|
167
|
+
});
|
|
168
|
+
const { issues } = outcome.validation;
|
|
169
|
+
if (!outcome.validation.ok && !call.validateOnly) {
|
|
170
|
+
return yield* new InvalidActionInput({ issues });
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
result: outcome.result ?? null,
|
|
174
|
+
validation: { ok: outcome.validation.ok, issues },
|
|
175
|
+
edits: outcome.edits,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The caller: their user record and the roles and permissions authentication resolved. Anyone may
|
|
181
|
+
* learn who they are and what they may do.
|
|
182
|
+
*/
|
|
183
|
+
function callerOf(backend) {
|
|
184
|
+
return Effect.gen(function* () {
|
|
185
|
+
const principal = yield* CurrentPrincipal;
|
|
186
|
+
// Authentication resolved the principal from this user a moment ago; its absence is a defect.
|
|
187
|
+
const user = yield* backend.get({ entity: "user", id: principal.id }).pipe(Effect.orDie);
|
|
188
|
+
return {
|
|
189
|
+
user: {
|
|
190
|
+
id: principal.id,
|
|
191
|
+
name: String(user.name),
|
|
192
|
+
email: typeof user.email === "string" ? user.email : null,
|
|
193
|
+
kind: principal.kind,
|
|
194
|
+
active: user.active === true,
|
|
195
|
+
},
|
|
196
|
+
roles: principal.roles,
|
|
197
|
+
permissions: principal.permissions,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
|
|
202
|
+
export function makeOperations(backend, plan, hooks, connections) {
|
|
203
|
+
const targets = relationshipTargets(plan);
|
|
204
|
+
return {
|
|
205
|
+
/** A page of records. Requires `read` on the entity and on every entity the query reaches. */
|
|
206
|
+
find: (planned, query) => findOf(backend, targets, planned.name, query),
|
|
207
|
+
/** One record. Requires `read` on the entity. */
|
|
208
|
+
get: (planned, id) => getOf(backend, planned.name, id),
|
|
209
|
+
/** A page of a record's history, newest first. Requires `read` on the entity. */
|
|
210
|
+
history: (planned, id, params) => historyOf(backend, planned.name, id, params),
|
|
211
|
+
/** The records a relationship reaches from one record, as a page of the target entity. */
|
|
212
|
+
traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
|
|
213
|
+
/** Runs an action as the caller, with the action's after-commit hook and connections. */
|
|
214
|
+
runAction: runActionOf(backend, plan.ontology, hooks, connections),
|
|
215
|
+
/** The caller's user, roles and permissions. It needs no permission. */
|
|
216
|
+
caller: callerOf(backend),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -66,6 +66,93 @@ declare const NotInvited_base: Schema.Class<NotInvited, Schema.TaggedStruct<"Not
|
|
|
66
66
|
*/
|
|
67
67
|
export declare class NotInvited extends NotInvited_base {
|
|
68
68
|
}
|
|
69
|
+
/** How a client proves itself at the token endpoint; `none` for a public client, with PKCE only. */
|
|
70
|
+
export type OAuthClientAuthMethod = "none" | "client_secret_post" | "client_secret_basic";
|
|
71
|
+
/** A client that registered itself to connect an agent (RFC 7591 dynamic registration). */
|
|
72
|
+
export interface OAuthClient {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly redirectUris: ReadonlyArray<string>;
|
|
76
|
+
readonly authMethod: OAuthClientAuthMethod;
|
|
77
|
+
/** SHA-256 of a confidential client's secret; null for a public client. */
|
|
78
|
+
readonly secretHash: string | null;
|
|
79
|
+
readonly clientUri: string | null;
|
|
80
|
+
}
|
|
81
|
+
/** An authorization request waiting, for a few minutes, for a member's consent. */
|
|
82
|
+
export interface OAuthRequest {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly clientId: string;
|
|
85
|
+
readonly redirectUri: string;
|
|
86
|
+
readonly state: string | null;
|
|
87
|
+
/** The PKCE S256 challenge the token request must answer. */
|
|
88
|
+
readonly codeChallenge: string;
|
|
89
|
+
readonly scope: string;
|
|
90
|
+
/** The MCP server the tokens are for (RFC 8707). */
|
|
91
|
+
readonly resource: string;
|
|
92
|
+
readonly expiresAt: Date;
|
|
93
|
+
}
|
|
94
|
+
/** What a member allowed a client to do. Its tokens stop working once it is revoked. */
|
|
95
|
+
export interface OAuthGrant {
|
|
96
|
+
readonly id: string;
|
|
97
|
+
readonly clientId: string;
|
|
98
|
+
readonly userId: string;
|
|
99
|
+
readonly scope: string;
|
|
100
|
+
readonly resource: string;
|
|
101
|
+
}
|
|
102
|
+
export type OAuthTokenKind = "code" | "access" | "refresh";
|
|
103
|
+
/** A code or token of a grant, stored only as the SHA-256 of its value. */
|
|
104
|
+
export interface OAuthToken {
|
|
105
|
+
readonly hash: string;
|
|
106
|
+
readonly grantId: string;
|
|
107
|
+
readonly kind: OAuthTokenKind;
|
|
108
|
+
readonly expiresAt: Date;
|
|
109
|
+
/** For a code: the redirect URI it was sent to, which the token request must repeat. */
|
|
110
|
+
readonly redirectUri?: string;
|
|
111
|
+
/** For a code: the PKCE challenge the token request's verifier must answer. */
|
|
112
|
+
readonly codeChallenge?: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A code or refresh token presented once more: `reused` when it was used before, which means it
|
|
116
|
+
* leaked, and the caller revokes its grant.
|
|
117
|
+
*/
|
|
118
|
+
export type UsedOAuthToken = {
|
|
119
|
+
readonly status: "valid";
|
|
120
|
+
readonly token: OAuthToken;
|
|
121
|
+
readonly grant: OAuthGrant;
|
|
122
|
+
} | {
|
|
123
|
+
readonly status: "reused";
|
|
124
|
+
readonly grant: OAuthGrant;
|
|
125
|
+
} | {
|
|
126
|
+
readonly status: "invalid";
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Where the MCP server's authorization server keeps clients, pending requests, grants and tokens:
|
|
130
|
+
* the runtime's OAuth tables in the workspace database. Expiry is checked against the database's
|
|
131
|
+
* clock; failures are defects.
|
|
132
|
+
*/
|
|
133
|
+
export interface OAuthStore {
|
|
134
|
+
readonly saveClient: (client: OAuthClient) => Effect.Effect<void>;
|
|
135
|
+
readonly findClient: (id: string) => Effect.Effect<OAuthClient | undefined>;
|
|
136
|
+
/** Stores a request, and forgets the expired ones. */
|
|
137
|
+
readonly saveRequest: (request: OAuthRequest) => Effect.Effect<void>;
|
|
138
|
+
/** A request that has not expired. */
|
|
139
|
+
readonly findRequest: (id: string) => Effect.Effect<OAuthRequest | undefined>;
|
|
140
|
+
/** Removes a request that has not expired and returns it: a request is decided once. */
|
|
141
|
+
readonly takeRequest: (id: string) => Effect.Effect<OAuthRequest | undefined>;
|
|
142
|
+
/** Records a grant with its authorization code. */
|
|
143
|
+
readonly saveGrant: (grant: OAuthGrant, code: OAuthToken) => Effect.Effect<void>;
|
|
144
|
+
/** Uses a code or refresh token, once, before it expires and while its grant stands. */
|
|
145
|
+
readonly useToken: (hash: string, kind: "code" | "refresh") => Effect.Effect<UsedOAuthToken>;
|
|
146
|
+
/** Stores new tokens, and forgets the expired ones. */
|
|
147
|
+
readonly saveTokens: (tokens: ReadonlyArray<OAuthToken>) => Effect.Effect<void>;
|
|
148
|
+
/** The grant of an access token that has not expired, unless the grant was revoked. */
|
|
149
|
+
readonly findAccess: (hash: string) => Effect.Effect<OAuthGrant | undefined>;
|
|
150
|
+
/** The grant of a token of any kind, revoked or not. */
|
|
151
|
+
readonly findGrantOfToken: (hash: string) => Effect.Effect<OAuthGrant | undefined>;
|
|
152
|
+
readonly revokeGrant: (id: string) => Effect.Effect<void>;
|
|
153
|
+
/** The member a grant acts for, with the roles they hold now; undefined once they may not sign in. */
|
|
154
|
+
readonly memberPrincipal: (userId: string) => Effect.Effect<Principal | undefined>;
|
|
155
|
+
}
|
|
69
156
|
declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
|
|
70
157
|
readonly blobs?: BlobRuntime;
|
|
71
158
|
readonly maintainBlobs?: Effect.Effect<{
|
|
@@ -102,6 +189,8 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
|
|
|
102
189
|
readonly resolveApiKey: (key: Redacted.Redacted<string>) => Effect.Effect<Principal | undefined>;
|
|
103
190
|
/** The manifest hash recorded in the `_ontology` catalog table, or `undefined` before the first. */
|
|
104
191
|
readonly appliedHash: Effect.Effect<string | undefined>;
|
|
192
|
+
/** The OAuth tables, for members who connect agents; without it, agents use API keys. */
|
|
193
|
+
readonly oauth?: OAuthStore;
|
|
105
194
|
}>;
|
|
106
195
|
/**
|
|
107
196
|
* The runtime as the HTTP service sees it, with its database and ontology already bound. Failures
|
|
@@ -228,6 +228,10 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
|
228
228
|
readonly permission: Schema.optionalKey<Schema.String>;
|
|
229
229
|
readonly input: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
230
230
|
readonly connections: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
231
|
+
readonly hints: Schema.optionalKey<Schema.Struct<{
|
|
232
|
+
readonly destructive: Schema.optionalKey<Schema.Boolean>;
|
|
233
|
+
readonly idempotent: Schema.optionalKey<Schema.Boolean>;
|
|
234
|
+
}>>;
|
|
231
235
|
}>>;
|
|
232
236
|
readonly permissions: Schema.optionalKey<Schema.Struct<{
|
|
233
237
|
readonly read: Schema.String;
|
|
@@ -339,6 +343,10 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
|
339
343
|
readonly permission: Schema.optionalKey<Schema.String>;
|
|
340
344
|
readonly input: Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>;
|
|
341
345
|
readonly connections: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
346
|
+
readonly hints: Schema.optionalKey<Schema.Struct<{
|
|
347
|
+
readonly destructive: Schema.optionalKey<Schema.Boolean>;
|
|
348
|
+
readonly idempotent: Schema.optionalKey<Schema.Boolean>;
|
|
349
|
+
}>>;
|
|
342
350
|
}>>;
|
|
343
351
|
readonly permissions: Schema.optionalKey<Schema.Struct<{
|
|
344
352
|
readonly read: Schema.String;
|
|
@@ -13,8 +13,8 @@ export interface OntologyService {
|
|
|
13
13
|
readonly dispose: () => Promise<void>;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* The ontology service: the derived HttpApi
|
|
17
|
-
* API-key authentication. Every response carries `x-ontology-hash`
|
|
18
|
-
* answer with a JSON error and never a stack or SQL.
|
|
16
|
+
* The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
|
|
17
|
+
* behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
|
|
18
|
+
* and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
|
|
19
19
|
*/
|
|
20
20
|
export declare function createOntologyService(ontology: Ontology, config: OntologyServiceConfig): OntologyService;
|
package/dist/service/service.js
CHANGED
|
@@ -11,6 +11,8 @@ import { RequestId, failureResponse, internalError, errorBodyForWire, routeNotFo
|
|
|
11
11
|
import { deployedConnections, localConnections } from "./connections.js";
|
|
12
12
|
import { DocsRoute } from "./docs.js";
|
|
13
13
|
import { handlersLayer } from "./handlers.js";
|
|
14
|
+
import { mcpLayer } from "./mcp/server.js";
|
|
15
|
+
import { oauthLayer } from "./oauth/server.js";
|
|
14
16
|
import { planApi } from "./plan.js";
|
|
15
17
|
/** The header every response carries: the hash of the manifest the service serves. */
|
|
16
18
|
export const ONTOLOGY_HASH_HEADER = "x-ontology-hash";
|
|
@@ -54,9 +56,9 @@ function decorate(response, requestId, hash) {
|
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
57
|
-
* The ontology service: the derived HttpApi
|
|
58
|
-
* API-key authentication. Every response carries `x-ontology-hash`
|
|
59
|
-
* answer with a JSON error and never a stack or SQL.
|
|
59
|
+
* The ontology service: the derived HttpApi and the MCP server at `/mcp`, wired to the runtime,
|
|
60
|
+
* behind Neon Auth, app-token and API-key authentication. Every response carries `x-ontology-hash`
|
|
61
|
+
* and `x-request-id`; failures answer with a JSON error and never a stack or SQL.
|
|
60
62
|
*/
|
|
61
63
|
export function createOntologyService(ontology, config) {
|
|
62
64
|
if (config.blobStorage &&
|
|
@@ -84,8 +86,11 @@ export function createOntologyService(ontology, config) {
|
|
|
84
86
|
authenticationLayer(verifyToken, exchangeSession),
|
|
85
87
|
ErrorResponsesLive,
|
|
86
88
|
storageMaintenanceAuthenticationLayer(config.storageMaintenanceKey),
|
|
87
|
-
])
|
|
88
|
-
const
|
|
89
|
+
]));
|
|
90
|
+
const mcp = config.mcp === false
|
|
91
|
+
? Layer.empty
|
|
92
|
+
: Layer.mergeAll(mcpLayer({ built, hooks, connections, verifyToken }), oauthLayer({ verifyToken }));
|
|
93
|
+
const application = Layer.mergeAll(routes, mcp, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
|
|
89
94
|
const web = HttpRouter.toWebHandler(application, { disableLogger: true });
|
|
90
95
|
const hash = ontology.manifest.hash;
|
|
91
96
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./nitro": {
|
|
25
|
+
"types": "./dist/nitro.d.ts",
|
|
26
|
+
"import": "./dist/nitro.js"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"bin": {
|
|
@@ -54,6 +58,7 @@
|
|
|
54
58
|
},
|
|
55
59
|
"devDependencies": {
|
|
56
60
|
"@electric-sql/pglite": "0.4.6",
|
|
61
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
57
62
|
"@types/node": "^24",
|
|
58
63
|
"effect": "4.0.0-rc.113",
|
|
59
64
|
"typescript": "^5.9",
|