@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.
Files changed (64) hide show
  1. package/README.md +84 -17
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +6 -3
  7. package/dist/index.js +5 -2
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/apply.js +2 -1
  13. package/dist/migrate/plan.d.ts +1 -1
  14. package/dist/migrate/plan.js +59 -22
  15. package/dist/migrate/storage.d.ts +15 -2
  16. package/dist/migrate/storage.js +63 -5
  17. package/dist/names.d.ts +4 -2
  18. package/dist/names.js +5 -1
  19. package/dist/query.d.ts +5 -2
  20. package/dist/query.js +6 -2
  21. package/dist/relationship.d.ts +36 -5
  22. package/dist/relationship.js +5 -3
  23. package/dist/render.d.ts +47 -3
  24. package/dist/render.js +117 -11
  25. package/dist/replication/model.d.ts +1 -1
  26. package/dist/replication/model.js +11 -1
  27. package/dist/runtime/agents.d.ts +9 -0
  28. package/dist/runtime/agents.js +145 -0
  29. package/dist/runtime/service-binding.js +2 -0
  30. package/dist/service/agent/protocol.d.ts +47 -0
  31. package/dist/service/agent/protocol.js +59 -0
  32. package/dist/service/agent/server.d.ts +27 -0
  33. package/dist/service/agent/server.js +235 -0
  34. package/dist/service/aggregate-platform.d.ts +60 -0
  35. package/dist/service/aggregate-platform.js +189 -0
  36. package/dist/service/aggregate.d.ts +42 -0
  37. package/dist/service/aggregate.js +145 -0
  38. package/dist/service/api.d.ts +5 -3
  39. package/dist/service/api.js +52 -6
  40. package/dist/service/authentication.d.ts +16 -5
  41. package/dist/service/authentication.js +51 -6
  42. package/dist/service/catalog.d.ts +10 -1
  43. package/dist/service/catalog.js +12 -3
  44. package/dist/service/config.d.ts +30 -1
  45. package/dist/service/config.js +53 -1
  46. package/dist/service/errors.d.ts +38 -0
  47. package/dist/service/errors.js +118 -0
  48. package/dist/service/handlers.d.ts +2 -1
  49. package/dist/service/handlers.js +66 -28
  50. package/dist/service/mcp/server.d.ts +6 -0
  51. package/dist/service/mcp/server.js +74 -17
  52. package/dist/service/mcp/tools.d.ts +13 -2
  53. package/dist/service/mcp/tools.js +165 -6
  54. package/dist/service/plan.js +7 -1
  55. package/dist/service/runtime-contract.d.ts +68 -0
  56. package/dist/service/runtime-identity.d.ts +59 -0
  57. package/dist/service/runtime-identity.js +102 -0
  58. package/dist/service/schemas.d.ts +99 -1
  59. package/dist/service/schemas.js +150 -2
  60. package/dist/service/service.d.ts +2 -2
  61. package/dist/service/service.js +24 -6
  62. package/dist/user.d.ts +1 -1
  63. package/dist/warehouse-backed.js +222 -121
  64. 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
+ }
@@ -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 prose routes. There is no PUT, PATCH or DELETE:
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 {};
@@ -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 prose routes. There is no PUT, PATCH or DELETE:
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
- * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
28
- * Failures answer the request directly, before its input is decoded.
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
- * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
18
- * Failures answer the request directly, before its input is decoded.
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 { OntologyManifest } from "../manifest.js";
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". */
@@ -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
- ? `one ${targetLabel}`
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
- optional: relationship.cardinality === "many" || relationship.optional === true,
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 ${source.plural}`,
57
+ backward: `${targetLabel} ${relationship.phrase[1]} ${many}${source.plural}`,
49
58
  description: relationship.description,
50
59
  };
51
60
  })),
@@ -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 a pair.
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 {};