@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,59 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
/** How long a member has to open the connect link, sign in and approve the pairing. */
|
|
4
|
+
export const PAIRING_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* The letters and digits a pairing code uses: no 0/O, 1/I/L, so that a code read aloud or typed from
|
|
7
|
+
* a phone comes out right. 31 symbols, 8 of them: about 40 bits, against ten minutes of guessing by
|
|
8
|
+
* signed-in members only.
|
|
9
|
+
*/
|
|
10
|
+
export const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
11
|
+
export const PAIRING_CODE_LENGTH = 8;
|
|
12
|
+
const PAIRING_CODE = new RegExp(`^[${PAIRING_CODE_ALPHABET}]{${PAIRING_CODE_LENGTH}}$`);
|
|
13
|
+
/** A new pairing code, from the operating system's random source without modulo bias. */
|
|
14
|
+
export function newPairingCode() {
|
|
15
|
+
let code = "";
|
|
16
|
+
for (let index = 0; index < PAIRING_CODE_LENGTH; index++) {
|
|
17
|
+
code += PAIRING_CODE_ALPHABET[randomInt(PAIRING_CODE_ALPHABET.length)];
|
|
18
|
+
}
|
|
19
|
+
return code;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A code as the member's browser sends it, in the canonical form it was hashed in: upper case,
|
|
23
|
+
* without the spaces or dashes someone may type between groups. `undefined` when it cannot be one.
|
|
24
|
+
*/
|
|
25
|
+
export function canonicalPairingCode(value) {
|
|
26
|
+
const code = value.replace(/[\s-]/g, "").toUpperCase();
|
|
27
|
+
return PAIRING_CODE.test(code) ? code : undefined;
|
|
28
|
+
}
|
|
29
|
+
const MAX_EXTERNAL_ID_LENGTH = 256;
|
|
30
|
+
const MAX_DISPLAY_LENGTH = 200;
|
|
31
|
+
/** A channel's name: `telegram`, `slack`. It is part of a URL path and of every identity. */
|
|
32
|
+
export const Provider = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_-]{0,31}$/)).annotate({
|
|
33
|
+
description: "The channel, such as telegram: lower case letters, digits, - and _.",
|
|
34
|
+
});
|
|
35
|
+
/** `POST /api/ontology/agent/pairings`: the account an unknown sender writes from. */
|
|
36
|
+
export const PairingRequest = Schema.Struct({
|
|
37
|
+
provider: Provider,
|
|
38
|
+
external_id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH)).annotate({ description: "The account's id on the channel." }),
|
|
39
|
+
display: Schema.optionalKey(Schema.NullOr(Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH))).annotate({
|
|
40
|
+
description: "How the channel names the account, shown to the member who approves.",
|
|
41
|
+
})),
|
|
42
|
+
});
|
|
43
|
+
/** An identity on the wire, as the member's connect page and settings show it. */
|
|
44
|
+
export function identityForWire(identity) {
|
|
45
|
+
return {
|
|
46
|
+
id: identity.id,
|
|
47
|
+
provider: identity.provider,
|
|
48
|
+
external_id: identity.externalId,
|
|
49
|
+
display: identity.display,
|
|
50
|
+
created_at: identity.createdAt.toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
|
|
54
|
+
export function memberForWire(input) {
|
|
55
|
+
return {
|
|
56
|
+
user: { id: input.principal.id, name: input.name },
|
|
57
|
+
roles: input.principal.roles,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { type SessionExchange, type TokenVerifier } from "../authentication.js";
|
|
4
|
+
import { RequestId } from "../errors.js";
|
|
5
|
+
import { type RateLimit } from "../rate-limit.js";
|
|
6
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
7
|
+
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
8
|
+
/** The agent's endpoints, under the ontology service's path. */
|
|
9
|
+
export declare const AGENT_PATH = "/api/ontology/agent";
|
|
10
|
+
/**
|
|
11
|
+
* Pairings one client address may start per minute; each one is a row in the database. The agent's
|
|
12
|
+
* functions share a few egress addresses, so the limit leaves room for a busy channel.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PAIRING_LIMIT: RateLimit;
|
|
15
|
+
export interface AgentServerOptions {
|
|
16
|
+
readonly verifyToken: TokenVerifier;
|
|
17
|
+
/** The deployment's runtime tokens; `undefined` while runtime authentication is disabled. */
|
|
18
|
+
readonly verifyRuntime: RuntimeTokenVerifier | undefined;
|
|
19
|
+
readonly exchangeSession: SessionExchange | undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The routes that pair channel accounts with members for the app's own agent. Runtime routes take
|
|
23
|
+
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
24
|
+
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
25
|
+
* Without the agent tables, it serves nothing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function agentLayer(options: AgentServerOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Layer, Redacted, Result, Schema } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
4
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
5
|
+
import { schemaIssues } from "../../runtime/validation.js";
|
|
6
|
+
import { BASE_PATH } from "../api.js";
|
|
7
|
+
import { CurrentPrincipal, withPrincipal, } from "../authentication.js";
|
|
8
|
+
import { RequestId, errorBodyForWire, forbidden, notFound, rateLimited, toServerResponse, unauthorized, unavailable, } from "../errors.js";
|
|
9
|
+
import { secretHash } from "../oauth/protocol.js";
|
|
10
|
+
import { clientAddress, fixedWindowLimiter, } from "../rate-limit.js";
|
|
11
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
12
|
+
import { logRuntimeRejection } from "../runtime-identity.js";
|
|
13
|
+
import { PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
|
|
14
|
+
/** The agent's endpoints, under the ontology service's path. */
|
|
15
|
+
export const AGENT_PATH = `${BASE_PATH}/agent`;
|
|
16
|
+
const PAIRINGS_PATH = `${AGENT_PATH}/pairings`;
|
|
17
|
+
const IDENTITIES_PATH = `${AGENT_PATH}/identities`;
|
|
18
|
+
/**
|
|
19
|
+
* Pairings one client address may start per minute; each one is a row in the database. The agent's
|
|
20
|
+
* functions share a few egress addresses, so the limit leaves room for a busy channel.
|
|
21
|
+
*/
|
|
22
|
+
export const PAIRING_LIMIT = { limit: 60, windowMs: 60_000, maxKeys: 10_000 };
|
|
23
|
+
/** A fresh code collides with a live one about never; a few attempts make it never in practice. */
|
|
24
|
+
const CODE_ATTEMPTS = 3;
|
|
25
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
26
|
+
function json(body, status = 200) {
|
|
27
|
+
return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
|
|
28
|
+
}
|
|
29
|
+
function jsonBody(request) {
|
|
30
|
+
return Effect.map(Effect.orDie(request.text), (text) => {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function invalidBody(requestId, issues) {
|
|
40
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire({
|
|
41
|
+
error: "invalid_request",
|
|
42
|
+
detail: "The request body is invalid.",
|
|
43
|
+
requestId,
|
|
44
|
+
issues,
|
|
45
|
+
}), { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
function pairingNotFound(requestId) {
|
|
48
|
+
return toServerResponse(notFound(requestId, "The pairing code expired or was already used. Send the agent a message for a new link."));
|
|
49
|
+
}
|
|
50
|
+
function routeParam(name) {
|
|
51
|
+
return Effect.map(HttpRouter.params, (params) => params[name] ?? "");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `POST /api/ontology/agent/pairings` (runtime): a sender the agent does not know gets a short code
|
|
55
|
+
* that a signed-in member approves on the app's connect page. Only the code's hash is stored.
|
|
56
|
+
*/
|
|
57
|
+
function createPairing(store, limiter) {
|
|
58
|
+
return (request) => Effect.gen(function* () {
|
|
59
|
+
const requestId = yield* RequestId;
|
|
60
|
+
const retryAfter = limiter.take(clientAddress(request));
|
|
61
|
+
if (retryAfter !== undefined) {
|
|
62
|
+
return toServerResponse(rateLimited(requestId, "Too many pairings; try again in a minute.", retryAfter));
|
|
63
|
+
}
|
|
64
|
+
const body = yield* jsonBody(request);
|
|
65
|
+
const decoded = Schema.decodeUnknownResult(PairingRequest)(body, {
|
|
66
|
+
errors: "all",
|
|
67
|
+
onExcessProperty: "error",
|
|
68
|
+
});
|
|
69
|
+
if (Result.isFailure(decoded)) {
|
|
70
|
+
return invalidBody(requestId, schemaIssues(decoded.failure));
|
|
71
|
+
}
|
|
72
|
+
const { provider, external_id: externalId, display } = decoded.success;
|
|
73
|
+
const trimmedDisplay = display?.trim() || null;
|
|
74
|
+
for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) {
|
|
75
|
+
const code = newPairingCode();
|
|
76
|
+
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
|
|
77
|
+
const saved = yield* store.savePairing({
|
|
78
|
+
id: randomUUID(),
|
|
79
|
+
codeHash: secretHash(code),
|
|
80
|
+
provider,
|
|
81
|
+
externalId,
|
|
82
|
+
display: trimmedDisplay,
|
|
83
|
+
expiresAt,
|
|
84
|
+
});
|
|
85
|
+
if (saved) {
|
|
86
|
+
return json({ code, expires_at: expiresAt.toISOString() }, 201);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return yield* Effect.die(new Error("No free pairing code after several attempts."));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** `GET /api/ontology/agent/pairings/:code` (member): what the connect page shows. */
|
|
93
|
+
function describePairing(store) {
|
|
94
|
+
return Effect.gen(function* () {
|
|
95
|
+
const requestId = yield* RequestId;
|
|
96
|
+
const code = canonicalPairingCode(yield* routeParam("code"));
|
|
97
|
+
const pairing = code === undefined ? undefined : yield* store.findPairing(secretHash(code));
|
|
98
|
+
if (pairing === undefined) {
|
|
99
|
+
return pairingNotFound(requestId);
|
|
100
|
+
}
|
|
101
|
+
return json({
|
|
102
|
+
provider: pairing.provider,
|
|
103
|
+
display: pairing.display,
|
|
104
|
+
expires_at: pairing.expiresAt.toISOString(),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `POST /api/ontology/agent/pairings/:code/approve` (member): pairs the account with the signed-in
|
|
110
|
+
* member, once. The account's previous identity and the member's previous identity on the same
|
|
111
|
+
* channel stop working: each has one live identity per channel, so an approval tricked out of a
|
|
112
|
+
* member displaces their own account, which they notice.
|
|
113
|
+
*/
|
|
114
|
+
function approvePairing(store, member) {
|
|
115
|
+
return Effect.gen(function* () {
|
|
116
|
+
const requestId = yield* RequestId;
|
|
117
|
+
const code = canonicalPairingCode(yield* routeParam("code"));
|
|
118
|
+
const identity = code === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: yield* store.approvePairing({
|
|
121
|
+
codeHash: secretHash(code),
|
|
122
|
+
userId: member.id,
|
|
123
|
+
identityId: randomUUID(),
|
|
124
|
+
});
|
|
125
|
+
if (identity === undefined) {
|
|
126
|
+
return pairingNotFound(requestId);
|
|
127
|
+
}
|
|
128
|
+
yield* Effect.logInfo("ontology.agent.paired").pipe(Effect.annotateLogs({ identityId: identity.id, provider: identity.provider, requestId }));
|
|
129
|
+
return json({ identity: identityForWire(identity) });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* `GET /api/ontology/agent/identities/:provider/:external_id` (runtime): the member an account acts
|
|
134
|
+
* as, with the roles they hold now. 404 for an account nobody paired; 403 once its identity was
|
|
135
|
+
* revoked or its member deactivated.
|
|
136
|
+
*/
|
|
137
|
+
function resolveIdentity(store) {
|
|
138
|
+
return Effect.gen(function* () {
|
|
139
|
+
const requestId = yield* RequestId;
|
|
140
|
+
const provider = yield* routeParam("provider");
|
|
141
|
+
const externalId = yield* routeParam("external_id");
|
|
142
|
+
const identity = yield* store.findIdentity({ provider, externalId });
|
|
143
|
+
if (identity === undefined) {
|
|
144
|
+
return toServerResponse(notFound(requestId, "No member paired this account."));
|
|
145
|
+
}
|
|
146
|
+
if (identity.revokedAt !== null) {
|
|
147
|
+
return toServerResponse(forbidden(requestId, "The account's pairing was revoked."));
|
|
148
|
+
}
|
|
149
|
+
const member = yield* store.member(identity.userId);
|
|
150
|
+
if (member === undefined) {
|
|
151
|
+
return toServerResponse(forbidden(requestId, "The member is inactive."));
|
|
152
|
+
}
|
|
153
|
+
return json(memberForWire(member));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
|
|
157
|
+
function listIdentities(store, member) {
|
|
158
|
+
return Effect.gen(function* () {
|
|
159
|
+
const identities = yield* store.listIdentities(member.id);
|
|
160
|
+
return json({ identities: identities.map(identityForWire) });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** `DELETE /api/ontology/agent/identities/:id` (member): revokes one of the member's identities. */
|
|
164
|
+
function revokeIdentity(store, member) {
|
|
165
|
+
return Effect.gen(function* () {
|
|
166
|
+
const requestId = yield* RequestId;
|
|
167
|
+
const id = yield* routeParam("id");
|
|
168
|
+
const revoked = yield* store.revokeIdentity({ id, userId: member.id });
|
|
169
|
+
if (!revoked) {
|
|
170
|
+
return toServerResponse(notFound(requestId, "You have no such identity."));
|
|
171
|
+
}
|
|
172
|
+
yield* Effect.logInfo("ontology.agent.unpaired").pipe(Effect.annotateLogs({ identityId: id, requestId }));
|
|
173
|
+
return HttpServerResponse.empty({ status: 204, headers: NO_STORE });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The routes that pair channel accounts with members for the app's own agent. Runtime routes take
|
|
178
|
+
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
179
|
+
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
180
|
+
* Without the agent tables, it serves nothing.
|
|
181
|
+
*/
|
|
182
|
+
export function agentLayer(options) {
|
|
183
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
184
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
185
|
+
const backend = yield* OntologyBackend;
|
|
186
|
+
const store = backend.agents;
|
|
187
|
+
if (store === undefined) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const limiter = fixedWindowLimiter(PAIRING_LIMIT);
|
|
191
|
+
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
192
|
+
const bearerOf = (request) => Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
|
|
193
|
+
const asRuntime = (answer) => (request) => Effect.gen(function* () {
|
|
194
|
+
const requestId = yield* RequestId;
|
|
195
|
+
const bearer = yield* bearerOf(request);
|
|
196
|
+
const token = Redacted.value(bearer);
|
|
197
|
+
const verified = options.verifyRuntime === undefined || token === ""
|
|
198
|
+
? undefined
|
|
199
|
+
: yield* Effect.promise(() => options.verifyRuntime(token));
|
|
200
|
+
switch (verified?.status) {
|
|
201
|
+
case "valid":
|
|
202
|
+
return yield* answer(request);
|
|
203
|
+
case "invalid":
|
|
204
|
+
yield* logRuntimeRejection(verified.reason);
|
|
205
|
+
return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
|
|
206
|
+
case "unavailable":
|
|
207
|
+
return toServerResponse(unavailable(requestId));
|
|
208
|
+
default:
|
|
209
|
+
// Any other credential is authenticated as usual, so that it gets the usual 401, and
|
|
210
|
+
// then refused: these routes are the agent's.
|
|
211
|
+
return yield* withPrincipal(bearer, options.verifyToken, backend, Effect.succeed(toServerResponse(forbidden(requestId, "Only the app's agent calls this route."))));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const asMember = (answer) => (request) => Effect.gen(function* () {
|
|
215
|
+
const bearer = yield* bearerOf(request);
|
|
216
|
+
const decided = Effect.gen(function* () {
|
|
217
|
+
const requestId = yield* RequestId;
|
|
218
|
+
const principal = yield* CurrentPrincipal;
|
|
219
|
+
if (principal.kind !== "member") {
|
|
220
|
+
return toServerResponse(forbidden(requestId, "Only a signed-in member can pair the app's agent."));
|
|
221
|
+
}
|
|
222
|
+
return yield* answer(request, principal);
|
|
223
|
+
});
|
|
224
|
+
return yield* withPrincipal(bearer, options.verifyToken, backend, decided, {
|
|
225
|
+
session: { request, exchange: options.exchangeSession },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
yield* router.add("POST", PAIRINGS_PATH, asRuntime(createPairing(store, limiter)));
|
|
229
|
+
yield* router.add("GET", `${PAIRINGS_PATH}/:code`, asMember(() => describePairing(store)));
|
|
230
|
+
yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
|
|
231
|
+
yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
|
|
232
|
+
yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
|
|
233
|
+
yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
import { AggregateUpstreamUnavailable } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long one aggregate may take end to end, the response included. BigQuery answers most within
|
|
5
|
+
* seconds; the margin covers a cold warehouse and the hops to and from SegmentStream.
|
|
6
|
+
*/
|
|
7
|
+
export declare const AGGREGATE_TIMEOUT_MS = 60000;
|
|
8
|
+
/**
|
|
9
|
+
* The hash of the manifest the service resolved and checked the request against. SegmentStream
|
|
10
|
+
* answers `409 ontology_version_mismatch` when its deployed manifest differs, so a request checked
|
|
11
|
+
* against one ontology never runs against another.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MANIFEST_HASH_HEADER = "x-ontology-manifest-hash";
|
|
14
|
+
/**
|
|
15
|
+
* The entities the service checked `<entity>:read` on, sorted and comma-separated. SegmentStream
|
|
16
|
+
* answers `409 aggregate_scope_mismatch` when it would read any other, so the service's resolution
|
|
17
|
+
* and SegmentStream's can never drift apart unnoticed.
|
|
18
|
+
*/
|
|
19
|
+
export declare const TOUCHED_HEADER = "x-ontology-touched";
|
|
20
|
+
/** SegmentStream would read beyond what the service checked: this deployment's SDK is behind. */
|
|
21
|
+
export declare const SCOPE_MISMATCH = "aggregate_scope_mismatch";
|
|
22
|
+
/** SegmentStream's answer: its status, its JSON body as sent, and that body parsed. */
|
|
23
|
+
export interface PlatformAnswer {
|
|
24
|
+
readonly status: number;
|
|
25
|
+
readonly text: string;
|
|
26
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
27
|
+
/** SegmentStream's `Retry-After`, on a 429 or a 503 that sent one. */
|
|
28
|
+
readonly retryAfter?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Sends a checked aggregate to SegmentStream: model-level, or the per-entity shorthand of `entity`.
|
|
32
|
+
* `touched` is every entity the service required `<entity>:read` on.
|
|
33
|
+
*/
|
|
34
|
+
export type AggregateTransport = (input: {
|
|
35
|
+
readonly entity?: string;
|
|
36
|
+
readonly body: unknown;
|
|
37
|
+
readonly requestId: string;
|
|
38
|
+
readonly touched: ReadonlyArray<string>;
|
|
39
|
+
}) => Effect.Effect<PlatformAnswer, AggregateUpstreamUnavailable>;
|
|
40
|
+
/**
|
|
41
|
+
* Where a deployment sends aggregates: its platform, its workspace and its app credential, with the
|
|
42
|
+
* hash of the manifest it serves.
|
|
43
|
+
*/
|
|
44
|
+
export interface AggregateEnvironment {
|
|
45
|
+
readonly segmentstreamUrl: string;
|
|
46
|
+
readonly workspaceId: string;
|
|
47
|
+
readonly appCredential?: Redacted.Redacted<string>;
|
|
48
|
+
readonly manifestHash: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
|
|
52
|
+
* `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
|
|
53
|
+
* http to another machine, which is logged once. Every request names the manifest it was checked
|
|
54
|
+
* against, and a redirect is refused, so the credential never leaves for another origin.
|
|
55
|
+
*
|
|
56
|
+
* Every answer but a 2xx is logged with both request ids, the status and the start of the body; a
|
|
57
|
+
* failed call with its message, stack and causes. The credential is redacted from all of them. A
|
|
58
|
+
* caller who goes away interrupts the call, which is not SegmentStream's failure.
|
|
59
|
+
*/
|
|
60
|
+
export declare function platformAggregates(environment: AggregateEnvironment, fetchImpl?: typeof fetch): AggregateTransport | undefined;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Data, Effect, Redacted } from "effect";
|
|
2
|
+
import { AggregateUpstreamUnavailable } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long one aggregate may take end to end, the response included. BigQuery answers most within
|
|
5
|
+
* seconds; the margin covers a cold warehouse and the hops to and from SegmentStream.
|
|
6
|
+
*/
|
|
7
|
+
export const AGGREGATE_TIMEOUT_MS = 60_000;
|
|
8
|
+
/** The header that carries the service's request id to SegmentStream, as connection calls do. */
|
|
9
|
+
const REQUEST_ID_HEADER = "x-segmentstream-request-id";
|
|
10
|
+
/**
|
|
11
|
+
* The hash of the manifest the service resolved and checked the request against. SegmentStream
|
|
12
|
+
* answers `409 ontology_version_mismatch` when its deployed manifest differs, so a request checked
|
|
13
|
+
* against one ontology never runs against another.
|
|
14
|
+
*/
|
|
15
|
+
export const MANIFEST_HASH_HEADER = "x-ontology-manifest-hash";
|
|
16
|
+
/**
|
|
17
|
+
* The entities the service checked `<entity>:read` on, sorted and comma-separated. SegmentStream
|
|
18
|
+
* answers `409 aggregate_scope_mismatch` when it would read any other, so the service's resolution
|
|
19
|
+
* and SegmentStream's can never drift apart unnoticed.
|
|
20
|
+
*/
|
|
21
|
+
export const TOUCHED_HEADER = "x-ontology-touched";
|
|
22
|
+
/**
|
|
23
|
+
* Statuses that refuse the deployment rather than the caller: its credential, its admission or the
|
|
24
|
+
* route itself. Passing them on would make the caller sign in again or look for a missing route.
|
|
25
|
+
*/
|
|
26
|
+
const DEPLOYMENT_FAULTS = {
|
|
27
|
+
401: "credential_rejected",
|
|
28
|
+
403: "credential_rejected",
|
|
29
|
+
404: "upstream_misconfigured",
|
|
30
|
+
};
|
|
31
|
+
/** SegmentStream would read beyond what the service checked: this deployment's SDK is behind. */
|
|
32
|
+
export const SCOPE_MISMATCH = "aggregate_scope_mismatch";
|
|
33
|
+
/** Statuses whose `Retry-After` the caller receives as SegmentStream sent it. */
|
|
34
|
+
const RETRY_AFTER_STATUSES = new Set([429, 503]);
|
|
35
|
+
/** A `Retry-After` value: seconds or an HTTP date. Anything else is not passed on. */
|
|
36
|
+
const RETRY_AFTER_VALUE = /^[0-9A-Za-z ,:+-]{1,64}$/;
|
|
37
|
+
/** The most of SegmentStream's body an operator log keeps. */
|
|
38
|
+
const SNIPPET_LENGTH = 500;
|
|
39
|
+
/** How many causes deep an operator log follows a failed call. */
|
|
40
|
+
const CAUSE_DEPTH = 3;
|
|
41
|
+
/** Hosts a deployment may reach over plain http: the machine itself, in development. */
|
|
42
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "[::1]"]);
|
|
43
|
+
/** The call ended without an answer; `aborted` when the caller went away first. */
|
|
44
|
+
class CallFailed extends Data.TaggedError("CallFailed") {
|
|
45
|
+
}
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
/** The credential travels only to SegmentStream over TLS, or over plain http to this machine. */
|
|
50
|
+
function securePlatform(origin) {
|
|
51
|
+
const { protocol, hostname } = new URL(origin);
|
|
52
|
+
if (protocol === "https:") {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return (protocol === "http:" && (LOOPBACK_HOSTS.has(hostname) || /^127(?:\.\d{1,3}){3}$/.test(hostname)));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
59
|
+
* of the credential replaced.
|
|
60
|
+
*/
|
|
61
|
+
function describeError(error, redact, depth = 0) {
|
|
62
|
+
if (!(error instanceof Error)) {
|
|
63
|
+
return redact(String(error));
|
|
64
|
+
}
|
|
65
|
+
const code = error.code;
|
|
66
|
+
const head = error.stack ?? `${error.name}: ${error.message}`;
|
|
67
|
+
const own = typeof code === "string" ? `${head} [${code}]` : head;
|
|
68
|
+
const cause = error.cause !== undefined && depth < CAUSE_DEPTH
|
|
69
|
+
? `\ncaused by: ${describeError(error.cause, redact, depth + 1)}`
|
|
70
|
+
: "";
|
|
71
|
+
return redact(`${own}${cause}`);
|
|
72
|
+
}
|
|
73
|
+
function parseObject(text) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(text);
|
|
76
|
+
return isRecord(parsed) ? { body: parsed } : { error: new TypeError("Not a JSON object.") };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return { error };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
|
|
84
|
+
* `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
|
|
85
|
+
* http to another machine, which is logged once. Every request names the manifest it was checked
|
|
86
|
+
* against, and a redirect is refused, so the credential never leaves for another origin.
|
|
87
|
+
*
|
|
88
|
+
* Every answer but a 2xx is logged with both request ids, the status and the start of the body; a
|
|
89
|
+
* failed call with its message, stack and causes. The credential is redacted from all of them. A
|
|
90
|
+
* caller who goes away interrupts the call, which is not SegmentStream's failure.
|
|
91
|
+
*/
|
|
92
|
+
export function platformAggregates(environment, fetchImpl = fetch) {
|
|
93
|
+
const { appCredential, segmentstreamUrl } = environment;
|
|
94
|
+
if (appCredential === undefined) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!securePlatform(segmentstreamUrl)) {
|
|
98
|
+
Effect.runSync(Effect.logError("ontology.aggregate.insecure_platform").pipe(Effect.annotateLogs({
|
|
99
|
+
diagnostic: "SEGMENTSTREAM_URL is not https; aggregates are unavailable.",
|
|
100
|
+
platform: segmentstreamUrl,
|
|
101
|
+
})));
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const credential = Redacted.value(appCredential);
|
|
105
|
+
const redact = (text) => text.split(credential).join("[redacted]");
|
|
106
|
+
const workspace = `${segmentstreamUrl}/api/v1/workspaces/` + encodeURIComponent(environment.workspaceId);
|
|
107
|
+
const urlOf = (entity) => entity === undefined
|
|
108
|
+
? `${workspace}/aggregate`
|
|
109
|
+
: `${workspace}/entities/${encodeURIComponent(entity)}/aggregate`;
|
|
110
|
+
const exchange = (input) => Effect.tryPromise({
|
|
111
|
+
try: async (signal) => {
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetchImpl(urlOf(input.entity), {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${credential}`,
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
accept: "application/json",
|
|
119
|
+
[REQUEST_ID_HEADER]: input.requestId,
|
|
120
|
+
[MANIFEST_HASH_HEADER]: environment.manifestHash,
|
|
121
|
+
[TOUCHED_HEADER]: input.touched.join(","),
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify(input.body),
|
|
124
|
+
redirect: "error",
|
|
125
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(AGGREGATE_TIMEOUT_MS)]),
|
|
126
|
+
});
|
|
127
|
+
const text = await response.text();
|
|
128
|
+
return {
|
|
129
|
+
status: response.status,
|
|
130
|
+
text,
|
|
131
|
+
retryAfter: response.headers.get("retry-after"),
|
|
132
|
+
platformRequestId: response.headers.get("x-request-id"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
throw new CallFailed({ error, aborted: signal.aborted });
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
catch: (error) => error instanceof CallFailed ? error : new CallFailed({ error, aborted: false }),
|
|
140
|
+
});
|
|
141
|
+
/** No answer: a timeout or an unreachable SegmentStream. */
|
|
142
|
+
const unanswered = (error, requestId) => {
|
|
143
|
+
const reason = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
|
|
144
|
+
return Effect.logWarning("ontology.aggregate.upstream_failed").pipe(Effect.annotateLogs({ requestId, diagnostic: reason, error: describeError(error, redact) }), Effect.andThen(Effect.fail(new AggregateUpstreamUnavailable({ reason }))));
|
|
145
|
+
};
|
|
146
|
+
/** An answer: passed on, or a 502 when it faults the deployment or is not a JSON object. */
|
|
147
|
+
const judge = (answer, requestId) => Effect.gen(function* () {
|
|
148
|
+
const parsed = parseObject(answer.text);
|
|
149
|
+
const body = "body" in parsed ? parsed.body : undefined;
|
|
150
|
+
const fault = DEPLOYMENT_FAULTS[answer.status] ?? (body === undefined ? "invalid_response" : undefined);
|
|
151
|
+
const platformRequestId = typeof body?.requestId === "string" ? body.requestId : answer.platformRequestId;
|
|
152
|
+
if (fault !== undefined || answer.status < 200 || answer.status >= 300) {
|
|
153
|
+
const code = typeof body?.error === "string" ? body.error : undefined;
|
|
154
|
+
const level = fault !== undefined || code === SCOPE_MISMATCH
|
|
155
|
+
? Effect.logError
|
|
156
|
+
: answer.status >= 500
|
|
157
|
+
? Effect.logWarning
|
|
158
|
+
: Effect.logInfo;
|
|
159
|
+
yield* level(fault === undefined
|
|
160
|
+
? "ontology.aggregate.platform_refused"
|
|
161
|
+
: "ontology.aggregate.upstream_failed").pipe(Effect.annotateLogs({
|
|
162
|
+
requestId,
|
|
163
|
+
platformRequestId: platformRequestId ?? "none",
|
|
164
|
+
status: answer.status,
|
|
165
|
+
diagnostic: fault ?? code ?? "refused",
|
|
166
|
+
body: redact(answer.text.slice(0, SNIPPET_LENGTH)),
|
|
167
|
+
...("error" in parsed ? { error: describeError(parsed.error, redact) } : {}),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
if (fault !== undefined || body === undefined) {
|
|
171
|
+
return yield* new AggregateUpstreamUnavailable({
|
|
172
|
+
reason: fault ?? "invalid_response",
|
|
173
|
+
status: answer.status,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const retryAfter = RETRY_AFTER_STATUSES.has(answer.status) &&
|
|
177
|
+
answer.retryAfter !== null &&
|
|
178
|
+
RETRY_AFTER_VALUE.test(answer.retryAfter)
|
|
179
|
+
? answer.retryAfter
|
|
180
|
+
: undefined;
|
|
181
|
+
return {
|
|
182
|
+
status: answer.status,
|
|
183
|
+
text: answer.text,
|
|
184
|
+
body,
|
|
185
|
+
...(retryAfter === undefined ? {} : { retryAfter }),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
return (input) => exchange(input).pipe(Effect.catchTag("CallFailed", (failed) => failed.aborted ? Effect.interrupt : unanswered(failed.error, input.requestId)), Effect.flatMap((answer) => judge(answer, input.requestId)));
|
|
189
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { OntologyManifest } from "../manifest.js";
|
|
3
|
+
import type { AggregateTransport, PlatformAnswer } from "./aggregate-platform.js";
|
|
4
|
+
import { CurrentPrincipal } from "./authentication.js";
|
|
5
|
+
import { AggregateLimited, AggregateUnavailable, AggregateUpstreamUnavailable, InvalidAggregateQuery, InvalidRequestBody, ReadDenied, RequestId } from "./errors.js";
|
|
6
|
+
import { type RateLimit, type RateLimiter } from "./rate-limit.js";
|
|
7
|
+
/**
|
|
8
|
+
* Aggregates one principal may run per minute, through both routes and the MCP tool together. Each
|
|
9
|
+
* runs a warehouse query with the deployment's credential, so one caller must not spend without
|
|
10
|
+
* bound.
|
|
11
|
+
*/
|
|
12
|
+
export declare const AGGREGATE_LIMIT: RateLimit;
|
|
13
|
+
/** Every way the facade refuses or fails an aggregate before SegmentStream answers it. */
|
|
14
|
+
export type AggregateFailure = AggregateLimited | InvalidRequestBody | InvalidAggregateQuery | ReadDenied | AggregateUnavailable | AggregateUpstreamUnavailable;
|
|
15
|
+
/**
|
|
16
|
+
* Runs an aggregate as the caller, whatever the transport: the HTTP route passes the parsed body,
|
|
17
|
+
* the MCP tool its arguments. Its answer is SegmentStream's, error statuses included.
|
|
18
|
+
*/
|
|
19
|
+
export type Aggregate = (body: unknown) => Effect.Effect<PlatformAnswer, AggregateFailure, CurrentPrincipal | RequestId>;
|
|
20
|
+
/** The service's aggregates: model-level, and the per-entity shorthand of each entity. */
|
|
21
|
+
export interface Aggregates {
|
|
22
|
+
/** `POST /api/ontology/aggregate` and the MCP `aggregate` tool. */
|
|
23
|
+
readonly model: Aggregate;
|
|
24
|
+
/** `POST /api/ontology/<entity>/aggregate`, the shorthand `segmentstream.ontology.<entity>`. */
|
|
25
|
+
readonly entity: (entity: string) => Aggregate;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The ontology service's aggregate facade (docs/ontology-aggregates-spec.md, "Surfaces"). Both
|
|
29
|
+
* routes and the MCP tool run as the caller:
|
|
30
|
+
* 0. the call counts against the caller's {@link AGGREGATE_LIMIT}, whichever way it came;
|
|
31
|
+
* 1. the request's shape and bounds are checked, and its names resolved against the service's own
|
|
32
|
+
* manifest into every entity it reads;
|
|
33
|
+
* 2. the caller needs `<entity>:read` on all of them;
|
|
34
|
+
* 3. the body goes to SegmentStream unchanged with the workspace app credential, naming the entities
|
|
35
|
+
* checked, or the answer is `aggregate_unavailable` where the deployment has no credential.
|
|
36
|
+
*/
|
|
37
|
+
export declare function makeAggregates(input: {
|
|
38
|
+
readonly manifest: OntologyManifest;
|
|
39
|
+
readonly transport: AggregateTransport | undefined;
|
|
40
|
+
/** One bucket per principal; {@link AGGREGATE_LIMIT} by default. */
|
|
41
|
+
readonly limiter?: RateLimiter;
|
|
42
|
+
}): Aggregates;
|