@segmentstream-cloud/ontology 0.12.0 → 0.14.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/README.md +84 -17
- package/dist/aggregate-routes.d.ts +95 -0
- package/dist/aggregate-routes.js +332 -0
- package/dist/analyze.js +83 -16
- package/dist/entity.d.ts +31 -15
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/manifest.d.ts +57 -23
- package/dist/manifest.js +77 -30
- package/dist/measure.d.ts +11 -2
- package/dist/measure.js +14 -1
- package/dist/migrate/apply.js +2 -1
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +59 -22
- package/dist/migrate/storage.d.ts +15 -2
- package/dist/migrate/storage.js +63 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +5 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.js +6 -2
- package/dist/relationship.d.ts +36 -5
- package/dist/relationship.js +5 -3
- package/dist/render.d.ts +47 -3
- package/dist/render.js +117 -11
- package/dist/replication/model.d.ts +1 -1
- package/dist/replication/model.js +11 -1
- package/dist/runtime/agents.d.ts +9 -0
- package/dist/runtime/agents.js +145 -0
- package/dist/runtime/service-binding.js +2 -0
- package/dist/service/agent/protocol.d.ts +47 -0
- package/dist/service/agent/protocol.js +59 -0
- package/dist/service/agent/server.d.ts +27 -0
- package/dist/service/agent/server.js +235 -0
- package/dist/service/aggregate-platform.d.ts +60 -0
- package/dist/service/aggregate-platform.js +189 -0
- package/dist/service/aggregate.d.ts +42 -0
- package/dist/service/aggregate.js +145 -0
- package/dist/service/api.d.ts +5 -3
- package/dist/service/api.js +52 -6
- package/dist/service/authentication.d.ts +16 -5
- package/dist/service/authentication.js +51 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +30 -1
- package/dist/service/config.js +53 -1
- package/dist/service/errors.d.ts +38 -0
- package/dist/service/errors.js +118 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +66 -28
- package/dist/service/mcp/server.d.ts +6 -0
- package/dist/service/mcp/server.js +74 -17
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/runtime-contract.d.ts +68 -0
- package/dist/service/runtime-identity.d.ts +59 -0
- package/dist/service/runtime-identity.js +102 -0
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.d.ts +2 -2
- package/dist/service/service.js +24 -6
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { resolveAggregateRoutes } from "../aggregate-routes.js";
|
|
3
|
+
import { isAllowed, permissionOf, READ_VERB } from "../permissions.js";
|
|
4
|
+
import { CurrentPrincipal } from "./authentication.js";
|
|
5
|
+
import { AggregateLimited, AggregateUnavailable, InvalidAggregateQuery, InvalidRequestBody, ReadDenied, RequestId, schemaIssues, } from "./errors.js";
|
|
6
|
+
import { fixedWindowLimiter } from "./rate-limit.js";
|
|
7
|
+
import { AggregateRequest, EntityAggregateRequest } from "./schemas.js";
|
|
8
|
+
/**
|
|
9
|
+
* Aggregates one principal may run per minute, through both routes and the MCP tool together. Each
|
|
10
|
+
* runs a warehouse query with the deployment's credential, so one caller must not spend without
|
|
11
|
+
* bound.
|
|
12
|
+
*/
|
|
13
|
+
export const AGGREGATE_LIMIT = { limit: 30, windowMs: 60_000, maxKeys: 10_000 };
|
|
14
|
+
function decode(schema, body) {
|
|
15
|
+
return Schema.decodeUnknownEffect(schema)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error.issue) })));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The first `orderBy` entry that names neither a requested measure nor a requested dimension, by
|
|
19
|
+
* its requested name or its identity. Ordering by anything else would read what the permission
|
|
20
|
+
* check never saw.
|
|
21
|
+
*/
|
|
22
|
+
function unrequestedOrder(request, routes) {
|
|
23
|
+
const dimensions = new Set([
|
|
24
|
+
...(request.dimensions ?? []),
|
|
25
|
+
...(routes.entities[0]?.dimensions ?? []).map(({ identity }) => identity),
|
|
26
|
+
]);
|
|
27
|
+
for (const order of request.orderBy ?? []) {
|
|
28
|
+
if (order.measure !== undefined && !request.measures.includes(order.measure)) {
|
|
29
|
+
return order.measure;
|
|
30
|
+
}
|
|
31
|
+
if (order.dimension !== undefined && !dimensions.has(order.dimension)) {
|
|
32
|
+
return order.dimension;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A model-level request's entities, as the shared resolver finds them: each measure's entity,
|
|
39
|
+
* every entity on a dimension's or a filter's route, intermediates included, and every entity a
|
|
40
|
+
* filter names even where it does not apply.
|
|
41
|
+
*/
|
|
42
|
+
function modelTouched(manifest, body) {
|
|
43
|
+
return Effect.gen(function* () {
|
|
44
|
+
const request = yield* decode(AggregateRequest, body);
|
|
45
|
+
const routes = resolveAggregateRoutes(manifest, {
|
|
46
|
+
measures: request.measures,
|
|
47
|
+
dimensions: request.dimensions ?? [],
|
|
48
|
+
filters: (request.filters ?? []).map((filter) => filter.dimension),
|
|
49
|
+
...(request.granularityOf === undefined ? {} : { granularityOf: request.granularityOf }),
|
|
50
|
+
});
|
|
51
|
+
if (!routes.ok) {
|
|
52
|
+
return yield* new InvalidAggregateQuery({ reason: routes.reason });
|
|
53
|
+
}
|
|
54
|
+
const unordered = unrequestedOrder(request, routes);
|
|
55
|
+
if (unordered !== undefined) {
|
|
56
|
+
return yield* new InvalidAggregateQuery({
|
|
57
|
+
reason: `orderBy names ${unordered}, which the request does not ask for.`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return routes.touched;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A per-entity request's entities: the entity, and every entity along each relative name it
|
|
65
|
+
* groups, filters, buckets or orders by, following the manifest's `rel.one` relationships. As on
|
|
66
|
+
* SegmentStream's per-entity route, a path may follow a self-relationship (`manager.name`).
|
|
67
|
+
*/
|
|
68
|
+
function entityTouched(manifest, entity, body) {
|
|
69
|
+
return Effect.gen(function* () {
|
|
70
|
+
const request = yield* decode(EntityAggregateRequest, body);
|
|
71
|
+
const entities = new Map(manifest.entities.map((candidate) => [candidate.name, candidate]));
|
|
72
|
+
const touched = new Set([entity]);
|
|
73
|
+
const names = [
|
|
74
|
+
...request.dimensions,
|
|
75
|
+
...(request.filters ?? []).map((filter) => filter.dimension),
|
|
76
|
+
...(request.granularityOf === undefined ? [] : [request.granularityOf]),
|
|
77
|
+
...(request.orderBy ?? []).flatMap((order) => order.dimension === undefined ? [] : [order.dimension]),
|
|
78
|
+
];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const segments = name.split(".");
|
|
81
|
+
let current = entities.get(entity);
|
|
82
|
+
if (segments.some((segment) => segment === "")) {
|
|
83
|
+
return yield* new InvalidAggregateQuery({ reason: `${name} is not a valid name.` });
|
|
84
|
+
}
|
|
85
|
+
for (const relationshipName of segments.slice(0, -1)) {
|
|
86
|
+
const relationship = current?.relationships.find((candidate) => candidate.name === relationshipName);
|
|
87
|
+
if (current === undefined || relationship === undefined) {
|
|
88
|
+
return yield* new InvalidAggregateQuery({
|
|
89
|
+
reason: `${current?.name ?? entity} has no relationship ${relationshipName}.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (relationship.cardinality !== "one") {
|
|
93
|
+
return yield* new InvalidAggregateQuery({
|
|
94
|
+
reason: `${relationshipName} is not a to-one relationship of ${current.name}.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
current = entities.get(relationship.to);
|
|
98
|
+
touched.add(relationship.to);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...touched].sort();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The ontology service's aggregate facade (docs/ontology-aggregates-spec.md, "Surfaces"). Both
|
|
106
|
+
* routes and the MCP tool run as the caller:
|
|
107
|
+
* 0. the call counts against the caller's {@link AGGREGATE_LIMIT}, whichever way it came;
|
|
108
|
+
* 1. the request's shape and bounds are checked, and its names resolved against the service's own
|
|
109
|
+
* manifest into every entity it reads;
|
|
110
|
+
* 2. the caller needs `<entity>:read` on all of them;
|
|
111
|
+
* 3. the body goes to SegmentStream unchanged with the workspace app credential, naming the entities
|
|
112
|
+
* checked, or the answer is `aggregate_unavailable` where the deployment has no credential.
|
|
113
|
+
*/
|
|
114
|
+
export function makeAggregates(input) {
|
|
115
|
+
const { manifest, transport } = input;
|
|
116
|
+
const limiter = input.limiter ?? fixedWindowLimiter(AGGREGATE_LIMIT);
|
|
117
|
+
const run = (request) => Effect.gen(function* () {
|
|
118
|
+
const principal = yield* CurrentPrincipal;
|
|
119
|
+
const requestId = yield* RequestId;
|
|
120
|
+
const retryAfterSeconds = limiter.take(principal.id);
|
|
121
|
+
if (retryAfterSeconds !== undefined) {
|
|
122
|
+
return yield* new AggregateLimited({ retryAfterSeconds });
|
|
123
|
+
}
|
|
124
|
+
const touched = yield* request.touchedBy(request.body);
|
|
125
|
+
const missing = touched
|
|
126
|
+
.map((entity) => permissionOf(entity, READ_VERB))
|
|
127
|
+
.filter((permission) => !isAllowed(permission, principal));
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
return yield* new ReadDenied({ permissions: missing });
|
|
130
|
+
}
|
|
131
|
+
if (transport === undefined) {
|
|
132
|
+
return yield* new AggregateUnavailable();
|
|
133
|
+
}
|
|
134
|
+
return yield* transport({
|
|
135
|
+
...(request.entity === undefined ? {} : { entity: request.entity }),
|
|
136
|
+
body: request.body,
|
|
137
|
+
requestId,
|
|
138
|
+
touched,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
model: (body) => run({ body, touchedBy: (value) => modelTouched(manifest, value) }),
|
|
143
|
+
entity: (entity) => (body) => run({ entity, body, touchedBy: (value) => entityTouched(manifest, entity, value) }),
|
|
144
|
+
};
|
|
145
|
+
}
|
package/dist/service/api.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Layer, Schema } from "effect";
|
|
|
2
2
|
import { HttpApi, HttpApiGroup, HttpApiMiddleware } from "effect/unstable/httpapi";
|
|
3
3
|
import type { Ontology } from "../ontology.js";
|
|
4
4
|
import { RequestId } from "./errors.js";
|
|
5
|
-
import { type ApiPlan } from "./plan.js";
|
|
5
|
+
import { type ApiPlan, type PlannedEntity } from "./plan.js";
|
|
6
6
|
import type { GeneratedActionsProvider } from "./runtime-contract.js";
|
|
7
7
|
import { type EntitySchemas } from "./schemas.js";
|
|
8
8
|
/** The path every ontology route lives under. */
|
|
@@ -26,6 +26,8 @@ declare const ErrorResponses_base: HttpApiMiddleware.ServiceClass<ErrorResponses
|
|
|
26
26
|
export declare class ErrorResponses extends ErrorResponses_base {
|
|
27
27
|
}
|
|
28
28
|
export declare const ErrorResponsesLive: Layer.Layer<ErrorResponses, never, never>;
|
|
29
|
+
/** Whether an entity declares measures, and so has `POST /api/ontology/<entity>/aggregate`. */
|
|
30
|
+
export declare function hasMeasures(planned: PlannedEntity): boolean;
|
|
29
31
|
export interface BuiltApi {
|
|
30
32
|
readonly plan: ApiPlan;
|
|
31
33
|
readonly api: OntologyApi;
|
|
@@ -39,8 +41,8 @@ export interface DeriveApiOptions {
|
|
|
39
41
|
/**
|
|
40
42
|
* The HttpApi of an ontology: per entity `find`, `query`, `get`, `history`, a traversal per
|
|
41
43
|
* relationship and inverse, and a route per generated and authored action; and the catalog, graph,
|
|
42
|
-
* OpenAPI, status, caller (`me`), sign-in check and
|
|
43
|
-
* every change is an action.
|
|
44
|
+
* OpenAPI, status, caller (`me`), sign-in check, prose and aggregate routes. There is no PUT, PATCH
|
|
45
|
+
* or DELETE: every change is an action.
|
|
44
46
|
*/
|
|
45
47
|
export declare function deriveApi(ontology: Ontology, options?: DeriveApiOptions): OntologyApi;
|
|
46
48
|
export {};
|
package/dist/service/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Effect, Layer, Schema } from "effect";
|
|
2
2
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi, } from "effect/unstable/httpapi";
|
|
3
|
+
import { AGGREGATE_ROUTE } from "../names.js";
|
|
3
4
|
import { storageGroup } from "../storage/api.js";
|
|
4
5
|
import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
|
|
5
6
|
import { permissionOf, READ_VERB } from "../permissions.js";
|
|
@@ -8,7 +9,7 @@ import { Authentication } from "./authentication.js";
|
|
|
8
9
|
import { actionDescription, requires } from "./descriptions.js";
|
|
9
10
|
import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
|
|
10
11
|
import { planApi, } from "./plan.js";
|
|
11
|
-
import { ActionResponse, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
|
|
12
|
+
import { ActionResponse, AggregateRequest, AggregateResult, EntityAggregateRequest, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
|
|
12
13
|
/** The path every ontology route lives under. */
|
|
13
14
|
export const BASE_PATH = "/api/ontology";
|
|
14
15
|
/** The group of the global routes. Entity names are snake_case, so it cannot clash with one. */
|
|
@@ -52,6 +53,20 @@ const actionErrors = [
|
|
|
52
53
|
ErrorSchemas.unprocessable,
|
|
53
54
|
ErrorSchemas.internal,
|
|
54
55
|
];
|
|
56
|
+
/**
|
|
57
|
+
* The facade's own refusals. SegmentStream's answers, success and errors alike, pass through as it
|
|
58
|
+
* sent them: its errors are `{ error, detail, requestId, retryable }`.
|
|
59
|
+
*/
|
|
60
|
+
const aggregateErrors = [
|
|
61
|
+
ErrorSchemas.invalidRequest,
|
|
62
|
+
ErrorSchemas.invalidAggregateQuery,
|
|
63
|
+
ErrorSchemas.readForbidden,
|
|
64
|
+
ErrorSchemas.unsupportedMediaType,
|
|
65
|
+
ErrorSchemas.rateLimited,
|
|
66
|
+
ErrorSchemas.internal,
|
|
67
|
+
ErrorSchemas.aggregateUpstreamUnavailable,
|
|
68
|
+
ErrorSchemas.aggregateUnavailable,
|
|
69
|
+
];
|
|
55
70
|
/** An action that calls connections also answers for the connections it could not use. */
|
|
56
71
|
const connectionActionErrors = [
|
|
57
72
|
...actionErrors,
|
|
@@ -62,6 +77,23 @@ const connectionActionErrors = [
|
|
|
62
77
|
function readPermission(entity) {
|
|
63
78
|
return permissionOf(entity, READ_VERB);
|
|
64
79
|
}
|
|
80
|
+
/** Whether an entity declares measures, and so has `POST /api/ontology/<entity>/aggregate`. */
|
|
81
|
+
export function hasMeasures(planned) {
|
|
82
|
+
return Object.keys(planned.resolved.entity.definition.measures ?? {}).length > 0;
|
|
83
|
+
}
|
|
84
|
+
/** The per-entity shorthand, which runs as the caller like the model-level route. */
|
|
85
|
+
function entityAggregateEndpoint(planned) {
|
|
86
|
+
const { plural } = planned.resolved.entity.definition;
|
|
87
|
+
const endpoint = HttpApiEndpoint.post("aggregate", `${BASE_PATH}/${planned.name}/aggregate`, {
|
|
88
|
+
payload: EntityAggregateRequest,
|
|
89
|
+
success: AggregateResult,
|
|
90
|
+
error: aggregateErrors,
|
|
91
|
+
});
|
|
92
|
+
return documented(endpoint, `Aggregate ${plural.toLowerCase()}`, `The entity's measures, with names relative to it: revenue, product.brand. dateRange bounds a ` +
|
|
93
|
+
"partitioned entity. Requires reading the entity and every entity along each path it " +
|
|
94
|
+
"groups, filters or orders by. Answered as POST /api/ontology/aggregate is, and counted " +
|
|
95
|
+
"against the same limit.");
|
|
96
|
+
}
|
|
65
97
|
function traversalEndpoint(planned, traversal, target, targetNames) {
|
|
66
98
|
const label = planned.resolved.entity.definition.label;
|
|
67
99
|
const reach = traversal.kind === "one" ? `one ${targetNames.label}` : `many ${targetNames.plural}`;
|
|
@@ -85,11 +117,12 @@ function entityGroup(planned, schemas, names) {
|
|
|
85
117
|
const own = schemas.get(planned.name);
|
|
86
118
|
const { definition } = planned.resolved.entity;
|
|
87
119
|
const base = `${BASE_PATH}/${planned.name}`;
|
|
120
|
+
const aggregate = hasMeasures(planned) ? [authenticated(entityAggregateEndpoint(planned))] : [];
|
|
88
121
|
if (definition.source !== undefined) {
|
|
89
122
|
return HttpApiGroup.make(planned.name).add(authenticated(HttpApiEndpoint.get("sourceCollection", base, {
|
|
90
123
|
success: Schema.Never,
|
|
91
124
|
error: recordErrors,
|
|
92
|
-
})));
|
|
125
|
+
})), ...aggregate);
|
|
93
126
|
}
|
|
94
127
|
const plural = definition.plural.toLowerCase();
|
|
95
128
|
const read = requires([readPermission(planned.name)]);
|
|
@@ -127,7 +160,7 @@ function entityGroup(planned, schemas, names) {
|
|
|
127
160
|
}), `Download ${property.name}`, `Requires ${planned.name}:read. Historical revisions must belong to this record and field.`)),
|
|
128
161
|
...planned.actions.map((action) => actionEndpoint(planned, action)),
|
|
129
162
|
];
|
|
130
|
-
const [first, ...rest] = endpoints.map(authenticated);
|
|
163
|
+
const [first, ...rest] = [...endpoints.map(authenticated), ...aggregate];
|
|
131
164
|
return HttpApiGroup.make(planned.name)
|
|
132
165
|
.add(first, ...rest)
|
|
133
166
|
.annotateMerge(OpenApi.annotations({ title: definition.plural, description: definition.description }));
|
|
@@ -165,7 +198,20 @@ function serviceGroup() {
|
|
|
165
198
|
}).middleware(ErrorResponses), "Whether an address may sign in", "Public and rate limited per client address. The app asks before it emails a code or a " +
|
|
166
199
|
"sign-in link. Under the invited policy, an active member's address is allowed: with " +
|
|
167
200
|
"code once they have signed in with Neon Auth, with link before; any other address, " +
|
|
168
|
-
"deactivated members included, is not. Under open, every address is allowed with link.")
|
|
201
|
+
"deactivated members included, is not. Under open, every address is allowed with link."), authenticated(documented(HttpApiEndpoint.post("aggregate", `${BASE_PATH}/${AGGREGATE_ROUTE}`, {
|
|
202
|
+
payload: AggregateRequest,
|
|
203
|
+
success: AggregateResult,
|
|
204
|
+
error: aggregateErrors,
|
|
205
|
+
}), "Aggregate measures across the ontology", "Numbers about the model: measures from several entities, grouped and filtered by " +
|
|
206
|
+
"properties of related entities. Look at or change records through find and actions; " +
|
|
207
|
+
"count, sum or compare through aggregate. Names are <entity>.<measure> and " +
|
|
208
|
+
"<entity>.<property>, or a route from a measure's entity: " +
|
|
209
|
+
"<entity>.<relationship>.<property>. Requires <entity>:read on every entity the " +
|
|
210
|
+
"request reads: each measure's entity, and every entity on a dimension's or a " +
|
|
211
|
+
"filter's route, and at most 30 aggregates a minute per caller, MCP included. The " +
|
|
212
|
+
"service then forwards the request to SegmentStream, which " +
|
|
213
|
+
"answers from the warehouse and the replica of stored records; its answer, errors " +
|
|
214
|
+
"included, is returned unchanged. Preview deployments answer aggregate_unavailable.")))
|
|
169
215
|
.annotateMerge(OpenApi.annotations({ title: SERVICE_GROUP, description: "The ontology as a whole." }));
|
|
170
216
|
}
|
|
171
217
|
export function buildApi(plan) {
|
|
@@ -197,8 +243,8 @@ const runtimeGeneratedActions = (entity, ontology) => generatedActions(ontology,
|
|
|
197
243
|
/**
|
|
198
244
|
* The HttpApi of an ontology: per entity `find`, `query`, `get`, `history`, a traversal per
|
|
199
245
|
* relationship and inverse, and a route per generated and authored action; and the catalog, graph,
|
|
200
|
-
* OpenAPI, status, caller (`me`), sign-in check and
|
|
201
|
-
* every change is an action.
|
|
246
|
+
* OpenAPI, status, caller (`me`), sign-in check, prose and aggregate routes. There is no PUT, PATCH
|
|
247
|
+
* or DELETE: every change is an action.
|
|
202
248
|
*/
|
|
203
249
|
export function deriveApi(ontology, options = {}) {
|
|
204
250
|
return buildApi(planApi(ontology, options.generatedActions ?? runtimeGeneratedActions)).api;
|
|
@@ -6,6 +6,7 @@ import type { Principal } from "../runtime.js";
|
|
|
6
6
|
import type { NeonAuthEnvironment } from "./config.js";
|
|
7
7
|
import { RequestId } from "./errors.js";
|
|
8
8
|
import { OntologyBackend, type SignInIdentity } from "./runtime-contract.js";
|
|
9
|
+
import { type RuntimeCredential, type RuntimeTokenVerifier } from "./runtime-identity.js";
|
|
9
10
|
declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@segmentstream-cloud/ontology/CurrentPrincipal", Principal>;
|
|
10
11
|
/** The principal the request runs as, provided by {@link Authentication}. */
|
|
11
12
|
export declare class CurrentPrincipal extends CurrentPrincipal_base {
|
|
@@ -23,9 +24,10 @@ declare const Authentication_base: HttpApiMiddleware.ServiceClass<Authentication
|
|
|
23
24
|
readonly bearer: HttpApiSecurity.Http;
|
|
24
25
|
}, CurrentPrincipal, readonly [import("effect/Schema").Top, import("effect/Schema").Top, import("effect/Schema").Top], RequestId>>;
|
|
25
26
|
/**
|
|
26
|
-
* Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
|
|
27
|
-
*
|
|
28
|
-
* Failures answer the request directly, before its
|
|
27
|
+
* Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token,
|
|
28
|
+
* an API key or the deployment's runtime token acting as a member, all as `Authorization: Bearer …`;
|
|
29
|
+
* without one, the app's Neon Auth session cookie. Failures answer the request directly, before its
|
|
30
|
+
* input is decoded.
|
|
29
31
|
*/
|
|
30
32
|
export declare class Authentication extends Authentication_base {
|
|
31
33
|
}
|
|
@@ -115,6 +117,9 @@ type Authenticated = {
|
|
|
115
117
|
readonly status: "rejected";
|
|
116
118
|
} | {
|
|
117
119
|
readonly status: "not_invited";
|
|
120
|
+
} | {
|
|
121
|
+
readonly status: "forbidden";
|
|
122
|
+
readonly detail: string;
|
|
118
123
|
} | {
|
|
119
124
|
readonly status: "unavailable";
|
|
120
125
|
};
|
|
@@ -125,6 +130,11 @@ export interface CredentialOptions {
|
|
|
125
130
|
* an access token is rejected, as it was issued for the MCP server only.
|
|
126
131
|
*/
|
|
127
132
|
readonly oauthResource?: string;
|
|
133
|
+
/**
|
|
134
|
+
* The deployment's own runtime token may authenticate here, acting as the member it names. Without
|
|
135
|
+
* it a runtime token is just an invalid token.
|
|
136
|
+
*/
|
|
137
|
+
readonly runtime?: RuntimeCredential;
|
|
128
138
|
}
|
|
129
139
|
/** Resolves a bearer credential to a principal. */
|
|
130
140
|
export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, options?: CredentialOptions): Effect.Effect<Authenticated, never, OntologyBackend>;
|
|
@@ -147,7 +157,8 @@ export declare function withPrincipal<E, R>(credential: Redacted.Redacted<string
|
|
|
147
157
|
}): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Exclude<R, CurrentPrincipal> | RequestId>;
|
|
148
158
|
/**
|
|
149
159
|
* The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
|
|
150
|
-
* a request without a bearer token may authenticate with the app's Neon Auth session cookie
|
|
160
|
+
* a request without a bearer token may authenticate with the app's Neon Auth session cookie; with
|
|
161
|
+
* `verifyRuntime`, the deployment's own runtime token may act as the member it names.
|
|
151
162
|
*/
|
|
152
|
-
export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange): Layer.Layer<Authentication, never, OntologyBackend>;
|
|
163
|
+
export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange, verifyRuntime?: RuntimeTokenVerifier): Layer.Layer<Authentication, never, OntologyBackend>;
|
|
153
164
|
export {};
|
|
@@ -3,9 +3,10 @@ import { HttpServerRequest } from "effect/unstable/http";
|
|
|
3
3
|
import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi";
|
|
4
4
|
import { createRemoteJWKSet, decodeJwt, errors, jwtVerify, } from "jose";
|
|
5
5
|
import { API_KEY_PREFIX } from "../runtime/principals.js";
|
|
6
|
-
import { RequestId, crossOriginSession, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
|
|
6
|
+
import { RequestId, crossOriginSession, forbidden, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
|
|
7
7
|
import { ACCESS_TOKEN_PREFIX, principalOfAccessToken } from "./oauth/protocol.js";
|
|
8
8
|
import { OntologyBackend } from "./runtime-contract.js";
|
|
9
|
+
import { ACT_AS_HEADER, logRuntimeRejection, runtimeCredential, } from "./runtime-identity.js";
|
|
9
10
|
import { ErrorSchemas } from "./schemas.js";
|
|
10
11
|
const CLOCK_TOLERANCE_SECONDS = 5;
|
|
11
12
|
const JWKS_TIMEOUT_MS = 5_000;
|
|
@@ -13,9 +14,10 @@ const JWKS_TIMEOUT_MS = 5_000;
|
|
|
13
14
|
export class CurrentPrincipal extends Context.Service()("@segmentstream-cloud/ontology/CurrentPrincipal") {
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
|
|
17
|
-
*
|
|
18
|
-
* Failures answer the request directly, before its
|
|
17
|
+
* Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token,
|
|
18
|
+
* an API key or the deployment's runtime token acting as a member, all as `Authorization: Bearer …`;
|
|
19
|
+
* without one, the app's Neon Auth session cookie. Failures answer the request directly, before its
|
|
20
|
+
* input is decoded.
|
|
19
21
|
*/
|
|
20
22
|
export class Authentication extends HttpApiMiddleware.Service()("@segmentstream-cloud/ontology/Authentication", {
|
|
21
23
|
security: {
|
|
@@ -274,6 +276,29 @@ function sessionMayAuthenticate(request) {
|
|
|
274
276
|
const origin = request.headers.origin;
|
|
275
277
|
return origin !== undefined && origin === requestOrigin(request);
|
|
276
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* The member a verified runtime caller acts for: the member `X-SegmentStream-Act-As` names, with the
|
|
281
|
+
* roles they hold now, while they are active and paired with the agent. Without the header, the
|
|
282
|
+
* runtime may call only the agent's own routes, which authenticate it themselves.
|
|
283
|
+
*/
|
|
284
|
+
function actingMember(actAs) {
|
|
285
|
+
return Effect.gen(function* () {
|
|
286
|
+
const backend = yield* OntologyBackend;
|
|
287
|
+
if (actAs === undefined) {
|
|
288
|
+
return {
|
|
289
|
+
status: "forbidden",
|
|
290
|
+
detail: `The app's agent names the member it acts for in ${ACT_AS_HEADER}.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const member = backend.agents === undefined ? undefined : yield* backend.agents.actingMember(actAs);
|
|
294
|
+
return member === undefined
|
|
295
|
+
? {
|
|
296
|
+
status: "forbidden",
|
|
297
|
+
detail: "The member is inactive or has not paired with the app's agent.",
|
|
298
|
+
}
|
|
299
|
+
: { status: "authenticated", principal: member };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
277
302
|
/** Resolves a bearer credential to a principal. */
|
|
278
303
|
export function authenticate(credential, verifyToken, options = {}) {
|
|
279
304
|
return Effect.gen(function* () {
|
|
@@ -296,6 +321,21 @@ export function authenticate(credential, verifyToken, options = {}) {
|
|
|
296
321
|
? { status: "rejected" }
|
|
297
322
|
: { status: "authenticated", principal: service };
|
|
298
323
|
}
|
|
324
|
+
if (options.runtime !== undefined) {
|
|
325
|
+
const { verify, actAs } = options.runtime;
|
|
326
|
+
const runtime = yield* Effect.promise(() => verify(token));
|
|
327
|
+
switch (runtime.status) {
|
|
328
|
+
case "valid":
|
|
329
|
+
return yield* actingMember(actAs);
|
|
330
|
+
case "invalid":
|
|
331
|
+
yield* logRuntimeRejection(runtime.reason);
|
|
332
|
+
return { status: "rejected" };
|
|
333
|
+
case "unavailable":
|
|
334
|
+
return { status: "unavailable" };
|
|
335
|
+
case "other":
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
299
339
|
const verification = yield* Effect.promise(() => verifyToken(token));
|
|
300
340
|
if (verification.status !== "valid") {
|
|
301
341
|
return verification.status === "invalid"
|
|
@@ -358,6 +398,8 @@ export function withPrincipal(credential, verifyToken, backend, effect, options
|
|
|
358
398
|
return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked.", options.challenge?.(false)));
|
|
359
399
|
case "not_invited":
|
|
360
400
|
return toServerResponse(notInvited(requestId));
|
|
401
|
+
case "forbidden":
|
|
402
|
+
return toServerResponse(forbidden(requestId, outcome.detail));
|
|
361
403
|
case "cross_origin":
|
|
362
404
|
return toServerResponse(crossOriginSession(requestId));
|
|
363
405
|
case "unavailable":
|
|
@@ -369,16 +411,19 @@ export function withPrincipal(credential, verifyToken, backend, effect, options
|
|
|
369
411
|
}
|
|
370
412
|
/**
|
|
371
413
|
* The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
|
|
372
|
-
* a request without a bearer token may authenticate with the app's Neon Auth session cookie
|
|
414
|
+
* a request without a bearer token may authenticate with the app's Neon Auth session cookie; with
|
|
415
|
+
* `verifyRuntime`, the deployment's own runtime token may act as the member it names.
|
|
373
416
|
*/
|
|
374
|
-
export function authenticationLayer(verifyToken, exchangeSession) {
|
|
417
|
+
export function authenticationLayer(verifyToken, exchangeSession, verifyRuntime) {
|
|
375
418
|
return Layer.effect(Authentication, Effect.gen(function* () {
|
|
376
419
|
const backend = yield* OntologyBackend;
|
|
377
420
|
return Authentication.of({
|
|
378
421
|
bearer: (httpEffect, { credential }) => Effect.gen(function* () {
|
|
379
422
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
423
|
+
const runtime = runtimeCredential(verifyRuntime, request);
|
|
380
424
|
return yield* withPrincipal(credential, verifyToken, backend, httpEffect, {
|
|
381
425
|
session: { request, exchange: exchangeSession },
|
|
426
|
+
...(runtime === undefined ? {} : { runtime }),
|
|
382
427
|
});
|
|
383
428
|
}),
|
|
384
429
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type OntologyManifest } from "../manifest.js";
|
|
2
2
|
/**
|
|
3
3
|
* The catalog for a reader: the manifest, with each entity's actions narrowed to the ones the given
|
|
4
4
|
* permissions may run. Without permissions it is the manifest itself.
|
|
@@ -10,6 +10,15 @@ export interface GraphRelationship {
|
|
|
10
10
|
readonly to: string;
|
|
11
11
|
readonly inverse: string;
|
|
12
12
|
readonly cardinality: "one" | "many";
|
|
13
|
+
/** The property pair of a relationship matched by value: one with a warehouse-backed end. */
|
|
14
|
+
readonly on?: {
|
|
15
|
+
readonly property: string;
|
|
16
|
+
readonly targetProperty: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Whether the relationship may lead to nothing. Only a stored → stored `rel.one` can promise a
|
|
20
|
+
* record; a value-matched reference is never checked against its target.
|
|
21
|
+
*/
|
|
13
22
|
readonly optional: boolean;
|
|
14
23
|
readonly phrase: readonly [string, string];
|
|
15
24
|
/** The relationship read from the declaring entity: "Lead is owned by one User". */
|
package/dist/service/catalog.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValueMatched } from "../manifest.js";
|
|
1
2
|
import { isAllowed } from "../permissions.js";
|
|
2
3
|
import { actionPermission } from "../render.js";
|
|
3
4
|
/**
|
|
@@ -33,8 +34,15 @@ export function graphOf(manifest) {
|
|
|
33
34
|
relationships: manifest.entities.flatMap((source) => source.relationships.map((relationship) => {
|
|
34
35
|
const target = entities.get(relationship.to);
|
|
35
36
|
const targetLabel = target?.label ?? relationship.to;
|
|
37
|
+
// Manifests before version 5 wrote `optional` on warehouse → stored relationships too.
|
|
38
|
+
const valueMatched = isValueMatched(relationship) ||
|
|
39
|
+
source.source !== undefined ||
|
|
40
|
+
target?.source !== undefined;
|
|
41
|
+
// As describe phrases them: nothing promises a value-matched relationship a record.
|
|
42
|
+
const one = valueMatched ? "at most one" : "one";
|
|
43
|
+
const many = valueMatched ? "" : "many ";
|
|
36
44
|
const forwardTarget = relationship.cardinality === "one"
|
|
37
|
-
?
|
|
45
|
+
? `${one} ${targetLabel}`
|
|
38
46
|
: `many ${target?.plural ?? relationship.to}`;
|
|
39
47
|
return {
|
|
40
48
|
from: source.name,
|
|
@@ -42,10 +50,11 @@ export function graphOf(manifest) {
|
|
|
42
50
|
to: relationship.to,
|
|
43
51
|
inverse: relationship.inverse,
|
|
44
52
|
cardinality: relationship.cardinality,
|
|
45
|
-
|
|
53
|
+
...(relationship.on !== undefined ? { on: relationship.on } : {}),
|
|
54
|
+
optional: relationship.cardinality === "many" || valueMatched || relationship.optional === true,
|
|
46
55
|
phrase: relationship.phrase,
|
|
47
56
|
forward: `${source.label} ${relationship.phrase[0]} ${forwardTarget}`,
|
|
48
|
-
backward: `${targetLabel} ${relationship.phrase[1]} many
|
|
57
|
+
backward: `${targetLabel} ${relationship.phrase[1]} ${many}${source.plural}`,
|
|
49
58
|
description: relationship.description,
|
|
50
59
|
};
|
|
51
60
|
})),
|
package/dist/service/config.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { BlobStorageBinding } from "../storage/provider.js";
|
|
|
3
3
|
import type { Principal } from "../runtime.js";
|
|
4
4
|
import { type ConnectionTransportFactory, type LocalConnectionContext } from "./connections.js";
|
|
5
5
|
import type { OntologyServiceRuntime } from "./runtime-contract.js";
|
|
6
|
+
import type { RuntimeIdentityConfig } from "./runtime-identity.js";
|
|
6
7
|
/** The environment of the workspace deployment's ontology service. */
|
|
7
8
|
export declare const ENVIRONMENT_VARIABLES: {
|
|
8
9
|
readonly databaseUrl: "SEGMENTSTREAM_ONTOLOGY_DATABASE_URL";
|
|
@@ -11,6 +12,12 @@ export declare const ENVIRONMENT_VARIABLES: {
|
|
|
11
12
|
readonly workspaceId: "SEGMENTSTREAM_WORKSPACE_ID";
|
|
12
13
|
readonly organizationId: "SEGMENTSTREAM_ORGANIZATION_ID";
|
|
13
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The workspace app credential (`ssw_…`), which Vercel injects into every service of a production
|
|
17
|
+
* deployment. The service forwards aggregates to SegmentStream with it; without it, aggregates are
|
|
18
|
+
* unavailable and everything else works.
|
|
19
|
+
*/
|
|
20
|
+
export declare const APP_CREDENTIAL_VARIABLE = "SEGMENTSTREAM_APP_CREDENTIAL";
|
|
14
21
|
/**
|
|
15
22
|
* The workspace's Neon Auth, which signs the app's users in. Provisioning sets both on workspaces
|
|
16
23
|
* that have it; a service without them accepts SegmentStream app tokens and API keys only.
|
|
@@ -19,6 +26,17 @@ export declare const NEON_AUTH_ENVIRONMENT_VARIABLES: {
|
|
|
19
26
|
readonly baseUrl: "NEON_AUTH_BASE_URL";
|
|
20
27
|
readonly jwksUrl: "NEON_AUTH_JWKS_URL";
|
|
21
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* The deployment's own runtime callers, such as the app's agent: the issuer and the audience of the
|
|
31
|
+
* team's Vercel OIDC tokens, set together by the platform on deployments that have an agent. Without
|
|
32
|
+
* them a runtime token is an invalid token. Tokens must also name the project in Vercel's
|
|
33
|
+
* `VERCEL_PROJECT_ID`, as the agent runs in the same Vercel project as the ontology service.
|
|
34
|
+
*/
|
|
35
|
+
export declare const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES: {
|
|
36
|
+
readonly issuer: "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER";
|
|
37
|
+
readonly audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE";
|
|
38
|
+
readonly projectId: "VERCEL_PROJECT_ID";
|
|
39
|
+
};
|
|
22
40
|
/** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
|
|
23
41
|
export interface NeonAuthEnvironment {
|
|
24
42
|
/** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
|
|
@@ -38,8 +56,18 @@ export interface OntologyEnvironment {
|
|
|
38
56
|
readonly applicationId: string;
|
|
39
57
|
readonly workspaceId: string;
|
|
40
58
|
readonly organizationId: string;
|
|
59
|
+
/**
|
|
60
|
+
* The workspace app credential. With it, `/api/ontology/aggregate` forwards to SegmentStream at
|
|
61
|
+
* `segmentstreamUrl` for `workspaceId`; previews and local development have none.
|
|
62
|
+
*/
|
|
63
|
+
readonly appCredential?: Redacted.Redacted<string>;
|
|
41
64
|
/** Present once the workspace has Neon Auth: members then sign in with its tokens. */
|
|
42
65
|
readonly neonAuth?: NeonAuthEnvironment;
|
|
66
|
+
/**
|
|
67
|
+
* Present on deployments with a runtime caller: the deployment's own Vercel OIDC tokens then
|
|
68
|
+
* authenticate, acting as the member `X-SegmentStream-Act-As` names.
|
|
69
|
+
*/
|
|
70
|
+
readonly runtimeIdentity?: RuntimeIdentityConfig;
|
|
43
71
|
/**
|
|
44
72
|
* The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
|
|
45
73
|
* call connections through SegmentStream with the deployment's Vercel OIDC token.
|
|
@@ -88,7 +116,8 @@ export declare class OntologyServiceConfigError extends OntologyServiceConfigErr
|
|
|
88
116
|
/**
|
|
89
117
|
* Reads the service's configuration from the environment. It fails closed: a missing or invalid
|
|
90
118
|
* variable throws {@link OntologyServiceConfigError} naming every missing variable, and never the
|
|
91
|
-
* value of the database URL. The Neon Auth variables are optional, but only as
|
|
119
|
+
* value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
|
|
120
|
+
* pairs. The app credential is optional and kept redacted.
|
|
92
121
|
*/
|
|
93
122
|
export declare function configFromEnv(environment?: Readonly<Record<string, string | undefined>>): OntologyEnvironment;
|
|
94
123
|
export {};
|