@segmentstream-cloud/ontology 0.13.0 → 0.15.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 (51) hide show
  1. package/README.md +65 -3
  2. package/dist/agent-chat.d.ts +75 -0
  3. package/dist/agent-chat.js +176 -0
  4. package/dist/analyze.d.ts +6 -2
  5. package/dist/analyze.js +29 -18
  6. package/dist/authoring.d.ts +6 -3
  7. package/dist/authoring.js +6 -3
  8. package/dist/index.d.ts +7 -2
  9. package/dist/index.js +5 -1
  10. package/dist/migrate/apply.js +2 -1
  11. package/dist/migrate/plan.js +21 -9
  12. package/dist/migrate/storage.d.ts +12 -0
  13. package/dist/migrate/storage.js +48 -0
  14. package/dist/names.d.ts +1 -1
  15. package/dist/names.js +1 -0
  16. package/dist/permissions.d.ts +11 -2
  17. package/dist/permissions.js +15 -3
  18. package/dist/replication/baseline.js +1 -1
  19. package/dist/replication/model.js +2 -2
  20. package/dist/replication/schema.js +1 -1
  21. package/dist/runtime/agents.d.ts +10 -0
  22. package/dist/runtime/agents.js +305 -0
  23. package/dist/runtime/generated-actions.d.ts +2 -1
  24. package/dist/runtime/generated-actions.js +18 -13
  25. package/dist/runtime/service-binding.js +2 -0
  26. package/dist/service/agent/channel.d.ts +16 -0
  27. package/dist/service/agent/channel.js +88 -0
  28. package/dist/service/agent/protocol.d.ts +61 -0
  29. package/dist/service/agent/protocol.js +85 -0
  30. package/dist/service/agent/server.d.ts +28 -0
  31. package/dist/service/agent/server.js +257 -0
  32. package/dist/service/aggregate-platform.d.ts +5 -0
  33. package/dist/service/aggregate-platform.js +1 -1
  34. package/dist/service/authentication.d.ts +16 -5
  35. package/dist/service/authentication.js +51 -6
  36. package/dist/service/config.d.ts +29 -2
  37. package/dist/service/config.js +52 -2
  38. package/dist/service/errors.d.ts +6 -0
  39. package/dist/service/errors.js +16 -0
  40. package/dist/service/handlers.d.ts +2 -1
  41. package/dist/service/handlers.js +2 -2
  42. package/dist/service/mcp/server.d.ts +6 -0
  43. package/dist/service/mcp/server.js +15 -10
  44. package/dist/service/operations.d.ts +6 -2
  45. package/dist/service/operations.js +13 -5
  46. package/dist/service/runtime-contract.d.ts +94 -0
  47. package/dist/service/runtime-identity.d.ts +59 -0
  48. package/dist/service/runtime-identity.js +102 -0
  49. package/dist/service/service.d.ts +2 -2
  50. package/dist/service/service.js +24 -6
  51. package/package.json +1 -1
@@ -64,7 +64,7 @@ function valueFields(entity, { mode }) {
64
64
  ? target
65
65
  : Schema.optionalKey(link.optional ? Schema.NullOr(target) : target);
66
66
  }
67
- else if (link.via === "join" && !link.inverse) {
67
+ else if (link.via === "join" && !link.inverse && !entity.resolved.builtIn) {
68
68
  fields[link.name] = Schema.optionalKey(Schema.Array(reference(link, `The id of a ${link.target} record.`)).annotate({
69
69
  description: `${relationshipDescription(entity, link)} The list replaces the current links.`,
70
70
  }));
@@ -87,7 +87,8 @@ function valuesOf(input, omit) {
87
87
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
88
88
  * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
89
89
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
90
- * and services by the app or the CLI.
90
+ * and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
91
+ * properties: the agent records chats and their members.
91
92
  */
92
93
  export function generatedActions(ontology, entityName) {
93
94
  if (ontology.manifest.entities.find((e) => e.name === entityName)?.source !== undefined) {
@@ -101,7 +102,7 @@ export function generatedActions(ontology, entityName) {
101
102
  }
102
103
  function buildGeneratedActions(ontology, entityName) {
103
104
  const entity = entityModel(ontologyModel(ontology), entityName);
104
- if (entity.resolved.builtIn) {
105
+ if (entity.resolved.builtIn && entity.resolved.permissions.update === undefined) {
105
106
  return {};
106
107
  }
107
108
  const label = entity.label.toLowerCase();
@@ -109,22 +110,26 @@ function buildGeneratedActions(ontology, entityName) {
109
110
  const managed = entity.fields
110
111
  .filter((field) => field.property?.definition.managed === true)
111
112
  .map((field) => field.name);
113
+ const update = action({
114
+ description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
115
+ (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
116
+ input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
117
+ handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
118
+ entity: entityName,
119
+ id: input.id,
120
+ values: valuesOf(input, ["id"]),
121
+ })),
122
+ });
123
+ if (entity.resolved.builtIn) {
124
+ return { update };
125
+ }
112
126
  const actions = {
113
127
  create: action({
114
128
  description: `Create ${article(label)}.`,
115
129
  input: Schema.Struct(valueFields(entity, { mode: "create" })),
116
130
  handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.create({ entity: entityName, values: input })),
117
131
  }),
118
- update: action({
119
- description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
120
- (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
121
- input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
122
- handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
123
- entity: entityName,
124
- id: input.id,
125
- values: valuesOf(input, ["id"]),
126
- })),
127
- }),
132
+ update,
128
133
  delete: action({
129
134
  description: `Delete ${article(label)}. Records that refer to it are deleted, cleared or protect it, ` +
130
135
  "as each relationship's onDelete says.",
@@ -6,6 +6,7 @@ import { maintainBlobStorage } from "../storage/maintenance.js";
6
6
  import { blobRuntime } from "../storage/sessions.js";
7
7
  import { readCatalog } from "../migrate/apply.js";
8
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
9
+ import { sqlAgentStore } from "./agents.js";
9
10
  import { generatedActions } from "./generated-actions.js";
10
11
  import { sqlOAuthStore } from "./oauth.js";
11
12
  import { runAction } from "./pipeline.js";
@@ -69,6 +70,7 @@ export function backendLayer(ontology, blobStorage) {
69
70
  return state?.enabled === true;
70
71
  }).pipe(Effect.orDie),
71
72
  oauth: sqlOAuthStore(ontology, sql),
73
+ agents: sqlAgentStore(ontology, sql),
72
74
  });
73
75
  }));
74
76
  }
@@ -0,0 +1,16 @@
1
+ import { AgentChannel } from "../../agent-chat.js";
2
+ import type { AgentStore } from "../runtime-contract.js";
3
+ /** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
4
+ export declare const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
5
+ /** The agent of one action request: its token and the agent tables. */
6
+ export type AgentChannelFactory = (input: {
7
+ /** The deployment's Vercel OIDC token, from the request or the environment. */
8
+ readonly identityToken: string | undefined;
9
+ readonly store: AgentStore | undefined;
10
+ }) => AgentChannel["Service"];
11
+ /**
12
+ * The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
13
+ * goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
14
+ * verifies as its own project's. A member's private chat is their live Telegram identity.
15
+ */
16
+ export declare function deployedAgentChannel(agentUrl: string, fetchImpl?: typeof fetch): AgentChannelFactory;
@@ -0,0 +1,88 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { TELEGRAM } from "../../agent-chat.js";
3
+ import { ActionRejected } from "../../errors.js";
4
+ import { describeError } from "../aggregate-platform.js";
5
+ /** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
6
+ export const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
7
+ /** How long the agent may take to accept a message; it answers once the message is queued. */
8
+ const DELIVERY_TIMEOUT_MS = 30_000;
9
+ /** The agent's error body: `{ "error": "channels_disabled" }` and the like. */
10
+ const AgentError = Schema.Struct({
11
+ error: Schema.optionalKey(Schema.String),
12
+ detail: Schema.optionalKey(Schema.String),
13
+ });
14
+ const rejected = (message) => new ActionRejected({ message });
15
+ const UNREACHABLE = "The agent could not be reached, so the message was not delivered. Try again.";
16
+ /**
17
+ * A delivery that got no answer (DNS, TLS, a timeout, a dropped body): the caught error is logged
18
+ * for the operator, with the token redacted, and the caller is told only that the agent was
19
+ * unreachable.
20
+ */
21
+ function deliveryFailed(error, token) {
22
+ const diagnostic = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
23
+ const redact = (text) => text.split(token).join("[redacted]");
24
+ return Effect.logWarning("ontology.agent.delivery_failed").pipe(Effect.annotateLogs({ diagnostic, error: describeError(error, redact) }), Effect.andThen(Effect.fail(rejected(UNREACHABLE))));
25
+ }
26
+ /**
27
+ * What the caller is told when the agent does not accept the message, by its status and body. The
28
+ * status and the agent's error code are logged; the body is not.
29
+ */
30
+ function refusal(status, text) {
31
+ const body = Option.getOrElse(Schema.decodeUnknownOption(Schema.fromJsonString(AgentError))(text), () => ({}));
32
+ return Effect.logWarning("ontology.agent.delivery_refused").pipe(Effect.annotateLogs({ status, diagnostic: body.error ?? "refused" }), Effect.andThen(Effect.fail(refusalMessage(status, body))));
33
+ }
34
+ /** The caller's message for a refusal, by the agent's status and decoded error body. */
35
+ function refusalMessage(status, body) {
36
+ switch (status) {
37
+ case 400:
38
+ return rejected(`The agent refused the message as invalid${body.detail ? `: ${body.detail}` : "."}`);
39
+ case 401:
40
+ case 403:
41
+ return rejected("The agent refused the deployment's token, so the message was not delivered.");
42
+ case 404:
43
+ return rejected("The deployed agent does not take messages from the ontology. Redeploy the agent with an " +
44
+ "SDK that does.");
45
+ case 503:
46
+ return body.error === "channels_disabled"
47
+ ? rejected("The agent's channels run only in the production deployment, so it cannot deliver " +
48
+ "the message from here.")
49
+ : rejected("The agent is temporarily unavailable, so the message was not delivered. Try again.");
50
+ default:
51
+ return rejected(UNREACHABLE);
52
+ }
53
+ }
54
+ /**
55
+ * The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
56
+ * goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
57
+ * verifies as its own project's. A member's private chat is their live Telegram identity.
58
+ */
59
+ export function deployedAgentChannel(agentUrl, fetchImpl = fetch) {
60
+ const url = `${agentUrl}${AGENT_MESSAGES_PATH}`;
61
+ return ({ identityToken, store }) => ({
62
+ privateChat: (userId) => store === undefined
63
+ ? Effect.succeed(undefined)
64
+ : store
65
+ .listIdentities(userId)
66
+ .pipe(Effect.map((identities) => identities.find((identity) => identity.provider === TELEGRAM)?.externalId)),
67
+ send: (message) => identityToken === undefined || identityToken === ""
68
+ ? Effect.fail(rejected("The request carries no Vercel OIDC token, which the agent requires. Messages are " +
69
+ "sent from the production deployment."))
70
+ : Effect.tryPromise({
71
+ try: async (signal) => {
72
+ const response = await fetchImpl(url, {
73
+ method: "POST",
74
+ headers: {
75
+ authorization: `Bearer ${identityToken}`,
76
+ "content-type": "application/json",
77
+ accept: "application/json",
78
+ },
79
+ body: JSON.stringify(message),
80
+ redirect: "error",
81
+ signal: AbortSignal.any([signal, AbortSignal.timeout(DELIVERY_TIMEOUT_MS)]),
82
+ });
83
+ return { ok: response.ok, status: response.status, text: await response.text() };
84
+ },
85
+ catch: (error) => error,
86
+ }).pipe(Effect.catch((error) => deliveryFailed(error, identityToken)), Effect.flatMap((answer) => answer.ok ? Effect.void : refusal(answer.status, answer.text))),
87
+ });
88
+ }
@@ -0,0 +1,61 @@
1
+ import { Schema } from "effect";
2
+ import type { Principal } from "../../runtime.js";
3
+ import type { AgentIdentity } from "../runtime-contract.js";
4
+ /** How long a member has to open the connect link, sign in and approve the pairing. */
5
+ export declare const PAIRING_TTL_MS: number;
6
+ /**
7
+ * The letters and digits a pairing code uses: no 0/O, 1/I/L, so that a code read aloud or typed from
8
+ * a phone comes out right. 31 symbols, 8 of them: about 40 bits, against ten minutes of guessing by
9
+ * signed-in members only.
10
+ */
11
+ export declare const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
12
+ export declare const PAIRING_CODE_LENGTH = 8;
13
+ /** A new pairing code, from the operating system's random source without modulo bias. */
14
+ export declare function newPairingCode(): string;
15
+ /**
16
+ * A code as the member's browser sends it, in the canonical form it was hashed in: upper case,
17
+ * without the spaces or dashes someone may type between groups. `undefined` when it cannot be one.
18
+ */
19
+ export declare function canonicalPairingCode(value: string): string | undefined;
20
+ /** A channel's name: `telegram`, `slack`. It is part of a URL path and of every identity. */
21
+ export declare const Provider: Schema.String;
22
+ /** `POST /api/ontology/agent/pairings`: the account an unknown sender writes from. */
23
+ export declare const PairingRequest: Schema.Struct<{
24
+ readonly provider: Schema.String;
25
+ readonly external_id: Schema.String;
26
+ readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
27
+ }>;
28
+ export type PairingRequest = typeof PairingRequest.Type;
29
+ /**
30
+ * `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
31
+ * the paired member who left it, or the agent itself leaving it, or the group it was before it
32
+ * became a supergroup.
33
+ */
34
+ export declare const ChatSeenRequest: Schema.Struct<{
35
+ readonly provider: Schema.String;
36
+ readonly externalId: Schema.String;
37
+ readonly title: Schema.String;
38
+ readonly memberId: Schema.optionalKey<Schema.String>;
39
+ readonly leftMemberId: Schema.optionalKey<Schema.String>;
40
+ readonly agentLeft: Schema.optionalKey<Schema.Literal<true>>;
41
+ readonly migratedFrom: Schema.optionalKey<Schema.String>;
42
+ }>;
43
+ /** An identity on the wire, as the member's connect page and settings show it. */
44
+ export declare function identityForWire(identity: AgentIdentity): {
45
+ id: string;
46
+ provider: string;
47
+ external_id: string;
48
+ display: string | null;
49
+ created_at: string;
50
+ };
51
+ /** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
52
+ export declare function memberForWire(input: {
53
+ readonly name: string;
54
+ readonly principal: Principal;
55
+ }): {
56
+ user: {
57
+ id: string;
58
+ name: string;
59
+ };
60
+ roles: readonly string[];
61
+ };
@@ -0,0 +1,85 @@
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
+ /** A chat's id on the channel. */
44
+ const ChatId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH));
45
+ /**
46
+ * `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
47
+ * the paired member who left it, or the agent itself leaving it, or the group it was before it
48
+ * became a supergroup.
49
+ */
50
+ export const ChatSeenRequest = Schema.Struct({
51
+ provider: Provider,
52
+ externalId: ChatId.annotate({ description: "The chat's id on the channel." }),
53
+ title: Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH)).annotate({
54
+ description: "The chat's name on the channel.",
55
+ }),
56
+ memberId: Schema.optionalKey(Schema.String.annotate({
57
+ description: "The user id of the member who wrote, when the sender is paired.",
58
+ })),
59
+ leftMemberId: Schema.optionalKey(Schema.String.annotate({
60
+ description: "The user id of a paired member who left the chat, to unlink from its members.",
61
+ })),
62
+ agentLeft: Schema.optionalKey(Schema.Literal(true).annotate({
63
+ description: "The agent itself left the chat or was removed: every member is unlinked.",
64
+ })),
65
+ migratedFrom: Schema.optionalKey(ChatId.annotate({
66
+ description: "The chat's id before it became a supergroup: its record moves to externalId.",
67
+ })),
68
+ });
69
+ /** An identity on the wire, as the member's connect page and settings show it. */
70
+ export function identityForWire(identity) {
71
+ return {
72
+ id: identity.id,
73
+ provider: identity.provider,
74
+ external_id: identity.externalId,
75
+ display: identity.display,
76
+ created_at: identity.createdAt.toISOString(),
77
+ };
78
+ }
79
+ /** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
80
+ export function memberForWire(input) {
81
+ return {
82
+ user: { id: input.principal.id, name: input.name },
83
+ roles: input.principal.roles,
84
+ };
85
+ }
@@ -0,0 +1,28 @@
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, and record the group
23
+ * chats it sees. Runtime routes take
24
+ * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
25
+ * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
26
+ * Without the agent tables, it serves nothing.
27
+ */
28
+ export declare function agentLayer(options: AgentServerOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
@@ -0,0 +1,257 @@
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 { ChatSeenRequest, 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
+ const CHATS_SEEN_PATH = `${AGENT_PATH}/chats/seen`;
19
+ /**
20
+ * Pairings one client address may start per minute; each one is a row in the database. The agent's
21
+ * functions share a few egress addresses, so the limit leaves room for a busy channel.
22
+ */
23
+ export const PAIRING_LIMIT = { limit: 60, windowMs: 60_000, maxKeys: 10_000 };
24
+ /** A fresh code collides with a live one about never; a few attempts make it never in practice. */
25
+ const CODE_ATTEMPTS = 3;
26
+ const NO_STORE = { "cache-control": "no-store" };
27
+ function json(body, status = 200) {
28
+ return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
29
+ }
30
+ function jsonBody(request) {
31
+ return Effect.map(Effect.orDie(request.text), (text) => {
32
+ try {
33
+ return JSON.parse(text);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ });
39
+ }
40
+ function invalidBody(requestId, issues) {
41
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire({
42
+ error: "invalid_request",
43
+ detail: "The request body is invalid.",
44
+ requestId,
45
+ issues,
46
+ }), { status: 400 });
47
+ }
48
+ function pairingNotFound(requestId) {
49
+ return toServerResponse(notFound(requestId, "The pairing code expired or was already used. Send the agent a message for a new link."));
50
+ }
51
+ function routeParam(name) {
52
+ return Effect.map(HttpRouter.params, (params) => params[name] ?? "");
53
+ }
54
+ /**
55
+ * `POST /api/ontology/agent/pairings` (runtime): a sender the agent does not know gets a short code
56
+ * that a signed-in member approves on the app's connect page. Only the code's hash is stored.
57
+ */
58
+ function createPairing(store, limiter) {
59
+ return (request) => Effect.gen(function* () {
60
+ const requestId = yield* RequestId;
61
+ const retryAfter = limiter.take(clientAddress(request));
62
+ if (retryAfter !== undefined) {
63
+ return toServerResponse(rateLimited(requestId, "Too many pairings; try again in a minute.", retryAfter));
64
+ }
65
+ const body = yield* jsonBody(request);
66
+ const decoded = Schema.decodeUnknownResult(PairingRequest)(body, {
67
+ errors: "all",
68
+ onExcessProperty: "error",
69
+ });
70
+ if (Result.isFailure(decoded)) {
71
+ return invalidBody(requestId, schemaIssues(decoded.failure));
72
+ }
73
+ const { provider, external_id: externalId, display } = decoded.success;
74
+ const trimmedDisplay = display?.trim() || null;
75
+ for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) {
76
+ const code = newPairingCode();
77
+ const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
78
+ const saved = yield* store.savePairing({
79
+ id: randomUUID(),
80
+ codeHash: secretHash(code),
81
+ provider,
82
+ externalId,
83
+ display: trimmedDisplay,
84
+ expiresAt,
85
+ });
86
+ if (saved) {
87
+ return json({ code, expires_at: expiresAt.toISOString() }, 201);
88
+ }
89
+ }
90
+ return yield* Effect.die(new Error("No free pairing code after several attempts."));
91
+ });
92
+ }
93
+ /** `GET /api/ontology/agent/pairings/:code` (member): what the connect page shows. */
94
+ function describePairing(store) {
95
+ return Effect.gen(function* () {
96
+ const requestId = yield* RequestId;
97
+ const code = canonicalPairingCode(yield* routeParam("code"));
98
+ const pairing = code === undefined ? undefined : yield* store.findPairing(secretHash(code));
99
+ if (pairing === undefined) {
100
+ return pairingNotFound(requestId);
101
+ }
102
+ return json({
103
+ provider: pairing.provider,
104
+ display: pairing.display,
105
+ expires_at: pairing.expiresAt.toISOString(),
106
+ });
107
+ });
108
+ }
109
+ /**
110
+ * `POST /api/ontology/agent/pairings/:code/approve` (member): pairs the account with the signed-in
111
+ * member, once. The account's previous identity and the member's previous identity on the same
112
+ * channel stop working: each has one live identity per channel, so an approval tricked out of a
113
+ * member displaces their own account, which they notice.
114
+ */
115
+ function approvePairing(store, member) {
116
+ return Effect.gen(function* () {
117
+ const requestId = yield* RequestId;
118
+ const code = canonicalPairingCode(yield* routeParam("code"));
119
+ const identity = code === undefined
120
+ ? undefined
121
+ : yield* store.approvePairing({
122
+ codeHash: secretHash(code),
123
+ userId: member.id,
124
+ identityId: randomUUID(),
125
+ });
126
+ if (identity === undefined) {
127
+ return pairingNotFound(requestId);
128
+ }
129
+ yield* Effect.logInfo("ontology.agent.paired").pipe(Effect.annotateLogs({ identityId: identity.id, provider: identity.provider, requestId }));
130
+ return json({ identity: identityForWire(identity) });
131
+ });
132
+ }
133
+ /**
134
+ * `GET /api/ontology/agent/identities/:provider/:external_id` (runtime): the member an account acts
135
+ * as, with the roles they hold now. 404 for an account nobody paired; 403 once its identity was
136
+ * revoked or its member deactivated.
137
+ */
138
+ function resolveIdentity(store) {
139
+ return Effect.gen(function* () {
140
+ const requestId = yield* RequestId;
141
+ const provider = yield* routeParam("provider");
142
+ const externalId = yield* routeParam("external_id");
143
+ const identity = yield* store.findIdentity({ provider, externalId });
144
+ if (identity === undefined) {
145
+ return toServerResponse(notFound(requestId, "No member paired this account."));
146
+ }
147
+ if (identity.revokedAt !== null) {
148
+ return toServerResponse(forbidden(requestId, "The account's pairing was revoked."));
149
+ }
150
+ const member = yield* store.member(identity.userId);
151
+ if (member === undefined) {
152
+ return toServerResponse(forbidden(requestId, "The member is inactive."));
153
+ }
154
+ return json(memberForWire(member));
155
+ });
156
+ }
157
+ /**
158
+ * `POST /api/ontology/agent/chats/seen` (runtime): the agent saw a message in a group chat. The chat
159
+ * is recorded, or its title kept current, a paired writer joins its members and a paired member who
160
+ * left is unlinked from them, or all of them when the agent itself left; the answer says what the
161
+ * chat is for, so that the agent knows it.
162
+ */
163
+ function seeChat(store) {
164
+ return (request) => Effect.gen(function* () {
165
+ const requestId = yield* RequestId;
166
+ const decoded = Schema.decodeUnknownResult(ChatSeenRequest)(yield* jsonBody(request), {
167
+ errors: "all",
168
+ onExcessProperty: "error",
169
+ });
170
+ if (Result.isFailure(decoded)) {
171
+ return invalidBody(requestId, schemaIssues(decoded.failure));
172
+ }
173
+ return json(yield* store.seeChat(decoded.success));
174
+ });
175
+ }
176
+ /** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
177
+ function listIdentities(store, member) {
178
+ return Effect.gen(function* () {
179
+ const identities = yield* store.listIdentities(member.id);
180
+ return json({ identities: identities.map(identityForWire) });
181
+ });
182
+ }
183
+ /** `DELETE /api/ontology/agent/identities/:id` (member): revokes one of the member's identities. */
184
+ function revokeIdentity(store, member) {
185
+ return Effect.gen(function* () {
186
+ const requestId = yield* RequestId;
187
+ const id = yield* routeParam("id");
188
+ const revoked = yield* store.revokeIdentity({ id, userId: member.id });
189
+ if (!revoked) {
190
+ return toServerResponse(notFound(requestId, "You have no such identity."));
191
+ }
192
+ yield* Effect.logInfo("ontology.agent.unpaired").pipe(Effect.annotateLogs({ identityId: id, requestId }));
193
+ return HttpServerResponse.empty({ status: 204, headers: NO_STORE });
194
+ });
195
+ }
196
+ /**
197
+ * The routes that pair channel accounts with members for the app's own agent, and record the group
198
+ * chats it sees. Runtime routes take
199
+ * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
200
+ * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
201
+ * Without the agent tables, it serves nothing.
202
+ */
203
+ export function agentLayer(options) {
204
+ return Layer.effectDiscard(Effect.gen(function* () {
205
+ const router = yield* HttpRouter.HttpRouter;
206
+ const backend = yield* OntologyBackend;
207
+ const store = backend.agents;
208
+ if (store === undefined) {
209
+ return;
210
+ }
211
+ const limiter = fixedWindowLimiter(PAIRING_LIMIT);
212
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
213
+ const bearerOf = (request) => Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
214
+ const asRuntime = (answer) => (request) => Effect.gen(function* () {
215
+ const requestId = yield* RequestId;
216
+ const bearer = yield* bearerOf(request);
217
+ const token = Redacted.value(bearer);
218
+ const verified = options.verifyRuntime === undefined || token === ""
219
+ ? undefined
220
+ : yield* Effect.promise(() => options.verifyRuntime(token));
221
+ switch (verified?.status) {
222
+ case "valid":
223
+ return yield* answer(request);
224
+ case "invalid":
225
+ yield* logRuntimeRejection(verified.reason);
226
+ return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
227
+ case "unavailable":
228
+ return toServerResponse(unavailable(requestId));
229
+ default:
230
+ // Any other credential is authenticated as usual, so that it gets the usual 401, and
231
+ // then refused: these routes are the agent's.
232
+ return yield* withPrincipal(bearer, options.verifyToken, backend, Effect.succeed(toServerResponse(forbidden(requestId, "Only the app's agent calls this route."))));
233
+ }
234
+ });
235
+ const asMember = (answer) => (request) => Effect.gen(function* () {
236
+ const bearer = yield* bearerOf(request);
237
+ const decided = Effect.gen(function* () {
238
+ const requestId = yield* RequestId;
239
+ const principal = yield* CurrentPrincipal;
240
+ if (principal.kind !== "member") {
241
+ return toServerResponse(forbidden(requestId, "Only a signed-in member can pair the app's agent."));
242
+ }
243
+ return yield* answer(request, principal);
244
+ });
245
+ return yield* withPrincipal(bearer, options.verifyToken, backend, decided, {
246
+ session: { request, exchange: options.exchangeSession },
247
+ });
248
+ });
249
+ yield* router.add("POST", PAIRINGS_PATH, asRuntime(createPairing(store, limiter)));
250
+ yield* router.add("GET", `${PAIRINGS_PATH}/:code`, asMember(() => describePairing(store)));
251
+ yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
252
+ yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
253
+ yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
254
+ yield* router.add("POST", CHATS_SEEN_PATH, asRuntime(seeChat(store)));
255
+ yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
256
+ }));
257
+ }
@@ -47,6 +47,11 @@ export interface AggregateEnvironment {
47
47
  readonly appCredential?: Redacted.Redacted<string>;
48
48
  readonly manifestHash: string;
49
49
  }
50
+ /**
51
+ * A failed call for the operator: name, message and stack, then each cause, with every occurrence
52
+ * of the credential replaced.
53
+ */
54
+ export declare function describeError(error: unknown, redact: (text: string) => string, depth?: number): string;
50
55
  /**
51
56
  * SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
52
57
  * `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
@@ -58,7 +58,7 @@ function securePlatform(origin) {
58
58
  * A failed call for the operator: name, message and stack, then each cause, with every occurrence
59
59
  * of the credential replaced.
60
60
  */
61
- function describeError(error, redact, depth = 0) {
61
+ export function describeError(error, redact, depth = 0) {
62
62
  if (!(error instanceof Error)) {
63
63
  return redact(String(error));
64
64
  }