@segmentstream-cloud/ontology 0.20.0 → 0.21.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 CHANGED
@@ -370,7 +370,7 @@ both are recorded, moves the members and a missing `purpose` and `whenToUse` to
370
370
  deletes the old one. `provider`, `externalId`, `title` and `lastMessageAt` are
371
371
  managed. Members set `purpose` and `whenToUse` with the generated
372
372
  `agent_chat.update`, the chat's only generated action. The built-in action
373
- `agent_chat.send_message` (`{ message, chat?, person? }`, `message` at most 4000 characters) asks the agent to write to the chat
373
+ `agent_chat.send_message` (`{ message, chat?, person?, document_ids? }`, `message` at most 4000 characters) asks the agent to write to the chat
374
374
  given, else to the person's one chat, else to their paired private chat; a person in several
375
375
  chats is refused with the candidates, or only their number without `agent_chat:read`. The service posts the message to the agent at
376
376
  `https://<VERCEL_PROJECT_PRODUCTION_URL>/eve/v1/telegram/messages` with the deployment's OIDC
@@ -382,6 +382,30 @@ chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_ch
382
382
 
383
383
  ### Agent attachments
384
384
 
385
+ Ontology SDK 0.21.0 adds `documentIds` to the authored action input (`document_ids` on REST/MCP):
386
+ an optional array of at most ten `document` record UUIDs. Before posting anything to the agent,
387
+ the action checks `document:read`, every record's existence and its single file property.
388
+ Missing files refuse the request; unreadable files require the read permission; ambiguous file
389
+ properties are refused. Requests without the field retain their previous behavior. The service
390
+ requires a paired member requester for requests with documents, since the agent downloads as
391
+ that member; services and unpaired requesters may still send requests without documents. It
392
+ forwards `documentIds` to `POST /eve/v1/telegram/messages`; agent SDK 0.5.0 includes the ids in
393
+ `<agent_request>` and sends the stored bytes with `segmentstream__attachments_send`.
394
+
395
+ `GET /api/ontology/agent/files/:entity/:id[/:property]` returns `{ file, download }` for the
396
+ current file property of an ontology record. Like temporary attachment routes, it requires a
397
+ production runtime bearer token, `X-SegmentStream-Act-As` naming an active paired member,
398
+ and the agent conversation/session headers. It checks `<entity>:read` before returning metadata
399
+ and rechecks live permissions when granting a download pinned to that revision. The response is
400
+ uncached; the download grant is short lived. Without a property the entity must have exactly one
401
+ file property. Missing records/files return `not_found`, missing read access `forbidden`, and
402
+ non-file or ambiguous properties `unsupported`.
403
+
404
+ Outbound Telegram uploads currently have an effective **2 MiB multipart request limit** in the
405
+ connection proxy, including overhead, even though Telegram accepts larger files. The agent splits
406
+ compatible albums to fit and returns per-file errors. Group purpose and document restrictions
407
+ still apply; a member's read access alone does not authorize sharing a restricted document in a group.
408
+
385
409
  A file a member sends the agent is a temporary attachment: platform metadata in the runtime
386
410
  table `_agent_attachments`, never an ontology record, with its bytes stored once as a revision in
387
411
  the ontology's file storage, beside uploads. It belongs to the member, the chat it arrived in and
@@ -14,6 +14,8 @@ export interface AgentMessage {
14
14
  };
15
15
  /** The request for the agent, written as an instruction to it. */
16
16
  readonly message: string;
17
+ /** Document record ids to send with the request; absent from older requests. */
18
+ readonly documentIds?: ReadonlyArray<string>;
17
19
  /** Whom the agent should address, when the caller named someone. */
18
20
  readonly person: {
19
21
  readonly id: string;
@@ -63,6 +65,7 @@ export declare const AgentChat: import("./entity.js").Entity<"agent_chat", {
63
65
  }, {
64
66
  readonly sendMessage: import("./action.js").Action<Schema.Struct<{
65
67
  readonly message: import("./property.js").Property<string, false, false>;
68
+ readonly documentIds: Schema.optionalKey<Schema.$Array<Schema.String>>;
66
69
  readonly chat: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
67
70
  readonly person: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
68
71
  }>, {
@@ -6,6 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "./permissions.js";
6
6
  import { p } from "./property.js";
7
7
  import { rel } from "./relationship.js";
8
8
  import { User } from "./user.js";
9
+ import { AgentFileRecords } from "./runtime/files.js";
9
10
  /** The only channel the agent delivers to so far, and the provider of its chats. */
10
11
  export const TELEGRAM = "telegram";
11
12
  /**
@@ -84,12 +85,16 @@ export const AgentChat = entity("agent_chat", {
84
85
  "chats is refused with the candidates (id, title, purpose, when to use; only their number " +
85
86
  "without agent_chat:read): call again with chat. The agent then writes in that chat and " +
86
87
  "handles the replies there, in that chat's own session; this action only delivers the " +
87
- "request and does not wait for an answer. Requires an agent deployed in the workspace.",
88
+ "request and does not wait for an answer. document_ids optionally names up to ten readable " +
89
+ "document records to attach with the question. Requires an agent deployed in the workspace.",
88
90
  input: Schema.Struct({
89
91
  message: p.text({
90
92
  maxLength: 4000,
91
93
  description: "The request for the agent, as an instruction: what to ask or tell, of whom.",
92
94
  }),
95
+ documentIds: Schema.optionalKey(Schema.Array(Schema.String.check(Schema.isUUID()))
96
+ .check(Schema.isMaxLength(10))
97
+ .annotate({ description: "Up to ten document record ids the requester may read." })),
93
98
  chat: p.ref("agent_chat", { optional: true, description: "The agent chat to write in." }),
94
99
  person: p.ref("user", {
95
100
  optional: true,
@@ -103,6 +108,19 @@ export const AgentChat = entity("agent_chat", {
103
108
  if (Option.isNone(channel)) {
104
109
  return yield* new ActionRejected({ message: NO_AGENT });
105
110
  }
111
+ if (input.documentIds !== undefined && input.documentIds.length > 0) {
112
+ const files = yield* Effect.serviceOption(AgentFileRecords);
113
+ if (Option.isNone(files)) {
114
+ return yield* new ActionRejected({
115
+ message: "This runtime cannot validate stored documents.",
116
+ });
117
+ }
118
+ for (const id of input.documentIds) {
119
+ yield* files.value
120
+ .resolve({ entity: "document", id }, principal)
121
+ .pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.fail(new ActionRejected({ message: error.message }))));
122
+ }
123
+ }
106
124
  const person = input.person === undefined ? undefined : yield* User.get(input.person);
107
125
  if (person !== undefined && (person.kind !== "member" || !person.active)) {
108
126
  return yield* new ActionRejected({
@@ -158,9 +176,15 @@ export const AgentChat = entity("agent_chat", {
158
176
  // The agent acts as a member only through their pairing.
159
177
  const actAs = principal.kind === "member" &&
160
178
  (yield* channel.value.privateChat(principal.id)) !== undefined;
179
+ if ((input.documentIds?.length ?? 0) > 0 && !actAs) {
180
+ return yield* new ActionRejected({
181
+ message: "Sending documents requires a member requester with a paired Telegram account.",
182
+ });
183
+ }
161
184
  yield* channel.value.send({
162
185
  target,
163
186
  message: input.message,
187
+ ...(input.documentIds === undefined ? {} : { documentIds: input.documentIds }),
164
188
  person: person === undefined ? null : { id: person.id, name: person.name },
165
189
  requester: {
166
190
  id: requester.id,
@@ -0,0 +1,39 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+ import { NotFound, PermissionDenied } from "../errors.js";
3
+ import type { OntologyManifest } from "../manifest.js";
4
+ import type { OntologyReads, Principal } from "../runtime.js";
5
+ export interface FileReference {
6
+ readonly entity: string;
7
+ readonly id: string;
8
+ readonly property?: string;
9
+ }
10
+ declare const UnsupportedFile_base: Schema.Class<UnsupportedFile, Schema.TaggedStruct<"UnsupportedFile", {
11
+ readonly message: Schema.String;
12
+ }>, import("effect/Cause").YieldableError>;
13
+ export declare class UnsupportedFile extends UnsupportedFile_base {
14
+ }
15
+ /** Resolve only declared file properties, checking permission before reading any record. */
16
+ export declare function resolveFileRecord(input: {
17
+ readonly manifest: OntologyManifest;
18
+ readonly reads: Pick<OntologyReads["Service"], "get">;
19
+ readonly principal: Principal;
20
+ readonly reference: FileReference;
21
+ }): Effect.Effect<{
22
+ entity: string;
23
+ id: string;
24
+ property: string;
25
+ file: {
26
+ readonly id: string;
27
+ readonly filename: string;
28
+ readonly contentType: string;
29
+ readonly size: number;
30
+ readonly createdAt: string;
31
+ };
32
+ }, PermissionDenied | NotFound | UnsupportedFile, never>;
33
+ declare const AgentFileRecords_base: Context.ServiceClass<AgentFileRecords, "@segmentstream-cloud/ontology/AgentFileRecords", {
34
+ readonly resolve: (reference: FileReference, principal: Principal) => ReturnType<typeof resolveFileRecord>;
35
+ }>;
36
+ /** File validation in an action's prepare, bound to its ontology and read runtime. */
37
+ export declare class AgentFileRecords extends AgentFileRecords_base {
38
+ }
39
+ export {};
@@ -0,0 +1,44 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+ import { BlobValue } from "../blob.js";
3
+ import { NotFound, PermissionDenied } from "../errors.js";
4
+ import { isAllowed, permissionOf, READ_VERB } from "../permissions.js";
5
+ export class UnsupportedFile extends Schema.TaggedError()("UnsupportedFile", {
6
+ message: Schema.String,
7
+ }) {
8
+ }
9
+ /** Resolve only declared file properties, checking permission before reading any record. */
10
+ export function resolveFileRecord(input) {
11
+ return Effect.gen(function* () {
12
+ const { entity: entityName, id, property: propertyName } = input.reference;
13
+ const permission = permissionOf(entityName, READ_VERB);
14
+ if (!isAllowed(permission, input.principal)) {
15
+ return yield* new PermissionDenied({ permission, message: "The file requires read access." });
16
+ }
17
+ const entity = input.manifest.entities.find((candidate) => candidate.name === entityName);
18
+ if (entity === undefined) {
19
+ return yield* new NotFound({ entity: entityName, id, message: "File record not found." });
20
+ }
21
+ const properties = entity.properties.filter((candidate) => candidate.type === "blob");
22
+ const property = propertyName === undefined
23
+ ? properties.length === 1
24
+ ? properties[0]
25
+ : undefined
26
+ : properties.find((candidate) => candidate.name === propertyName);
27
+ if (property === undefined) {
28
+ return yield* new UnsupportedFile({
29
+ message: "Name a file property; the entity must have exactly one when property is omitted.",
30
+ });
31
+ }
32
+ const record = yield* input.reads.get({ entity: entityName, id });
33
+ const file = record[property.name];
34
+ if (file === null || file === undefined) {
35
+ return yield* new NotFound({ entity: entityName, id, message: "File not found." });
36
+ }
37
+ // Runtime records are decoded already; malformed stored metadata is an operator failure.
38
+ const decoded = yield* Schema.decodeUnknownEffect(BlobValue)(file).pipe(Effect.orDie);
39
+ return { entity: entityName, id, property: property.name, file: decoded };
40
+ });
41
+ }
42
+ /** File validation in an action's prepare, bound to its ontology and read runtime. */
43
+ export class AgentFileRecords extends Context.Service()("@segmentstream-cloud/ontology/AgentFileRecords") {
44
+ }
@@ -14,6 +14,7 @@ import { UnknownAction, ValidationIssue } from "./errors.js";
14
14
  import { generatedActions } from "./generated-actions.js";
15
15
  import { Edit } from "./history.js";
16
16
  import { makeOntologyRuntime } from "./sql-runtime.js";
17
+ import { AgentFileRecords, resolveFileRecord } from "./files.js";
17
18
  import { validate } from "./validation.js";
18
19
  /** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
19
20
  export const PREPARE_TIMEOUT = Duration.seconds(60);
@@ -243,7 +244,9 @@ function prepare(ontology, sql, action, context, connections, qualified) {
243
244
  find: ({ entity, options }) => runtime.find({ entity, options: { ...options, forUpdate: false } }),
244
245
  };
245
246
  const run = Effect.suspend(() => action.prepare(context));
246
- return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.provideContext(connectionContext(action.connections, transport ?? NO_CONNECTIONS)), Effect.timeoutOrElse({
247
+ return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.provideService(AgentFileRecords, {
248
+ resolve: (reference, principal) => resolveFileRecord({ manifest: ontology.manifest, reads, reference, principal }),
249
+ }), Effect.provideContext(connectionContext(action.connections, transport ?? NO_CONNECTIONS)), Effect.timeoutOrElse({
247
250
  duration: PREPARE_TIMEOUT,
248
251
  orElse: () => Effect.fail(new ConnectionError({
249
252
  connection: action.connections.map((handle) => handle.key).join(","),
@@ -7,8 +7,9 @@ import { RequestId } from "../errors.js";
7
7
  import type { ApiPlan } from "../plan.js";
8
8
  import type { ReplicationKick } from "../replication.js";
9
9
  import { OntologyBackend } from "../runtime-contract.js";
10
- import { type RuntimeTokenVerifier } from "../runtime-identity.js";
10
+ import type { RuntimeTokenVerifier } from "../runtime-identity.js";
11
11
  import type { AgentChannelFactory } from "./channel.js";
12
+ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
12
13
  /**
13
14
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
15
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
@@ -17,10 +18,6 @@ import type { AgentChannelFactory } from "./channel.js";
17
18
  * tool arguments; every operation is confined to that member, chat and session.
18
19
  */
19
20
  export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
20
- /** The chat an attachment belongs to, such as `telegram:12345`. */
21
- export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
- /** The agent session the request comes from. */
23
- export declare const SESSION_HEADER = "x-segmentstream-agent-session";
24
21
  export interface AttachmentRoutesOptions {
25
22
  readonly plan: ApiPlan;
26
23
  readonly hooks: ReadonlyMap<string, AfterCommitHook>;
@@ -1,14 +1,14 @@
1
- import { Cause, Effect, Layer, Redacted, Result, Schema } from "effect";
2
- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
- import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
1
+ import { Cause, Effect, Layer, Result, Schema } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
4
3
  import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
5
4
  import { schemaIssues } from "../../runtime/validation.js";
6
5
  import { CurrentPrincipal } from "../authentication.js";
7
- import { RequestId, errorBodyForWire, failureResponse, forbidden, toServerResponse, unauthorized, unavailable, } from "../errors.js";
6
+ import { RequestId, errorBodyForWire, failureResponse } from "../errors.js";
8
7
  import { identityToken, makeOperations } from "../operations.js";
9
8
  import { OntologyBackend } from "../runtime-contract.js";
10
- import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
9
+ import { authenticateAgentCaller } from "./authentication.js";
11
10
  import { AGENT_PATH } from "./server.js";
11
+ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
12
12
  /**
13
13
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
14
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
@@ -17,12 +17,6 @@ import { AGENT_PATH } from "./server.js";
17
17
  * tool arguments; every operation is confined to that member, chat and session.
18
18
  */
19
19
  export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
20
- /** The chat an attachment belongs to, such as `telegram:12345`. */
21
- export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
- /** The agent session the request comes from. */
23
- export const SESSION_HEADER = "x-segmentstream-agent-session";
24
- const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
25
- const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
26
20
  const NO_STORE = { "cache-control": "no-store" };
27
21
  const Received = Schema.Struct({
28
22
  source: Schema.Literals(ATTACHMENT_SOURCES),
@@ -111,39 +105,16 @@ export function attachmentsLayer(options) {
111
105
  return;
112
106
  }
113
107
  const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
114
- const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
115
108
  /** Authenticates the runtime, then the member it acts for, the chat and the session. */
116
109
  const asAgent = (answer) => (request) => Effect.gen(function* () {
117
- const requestId = yield* RequestId;
118
- const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
119
- const token = Redacted.value(bearer);
120
- const verified = token === "" ? undefined : yield* Effect.promise(() => verifyRuntime(token));
121
- switch (verified?.status) {
122
- case "valid":
123
- break;
124
- case "invalid":
125
- yield* logRuntimeRejection(verified.reason);
126
- return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
127
- case "unavailable":
128
- return toServerResponse(unavailable(requestId));
129
- default:
130
- return toServerResponse(forbidden(requestId, "Only the app's agent calls this route."));
131
- }
132
- const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
133
- const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
134
- const session = request.headers[SESSION_HEADER]?.trim() ?? "";
135
- const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
136
- if (member === undefined) {
137
- return toServerResponse(forbidden(requestId, "The member is inactive or has not paired with the agent."));
138
- }
139
- if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
140
- return toServerResponse(forbidden(requestId, "The agent names the chat and session of every request."));
141
- }
142
- return yield* answer(request, {
143
- principal: member,
144
- conversation,
145
- ...(session === "" ? {} : { sessionId: session }),
146
- }, attachments);
110
+ const authenticated = yield* authenticateAgentCaller({
111
+ request,
112
+ agents,
113
+ verify: verifyRuntime,
114
+ });
115
+ return authenticated.ok
116
+ ? yield* answer(request, authenticated.caller, attachments)
117
+ : authenticated.response;
147
118
  });
148
119
  const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
149
120
  yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
@@ -0,0 +1,20 @@
1
+ import { Effect } from "effect";
2
+ import { HttpServerRequest } from "effect/unstable/http";
3
+ import type { AttachmentCaller } from "../../storage/attachments.js";
4
+ import { RequestId } from "../errors.js";
5
+ import type { AgentStore } from "../runtime-contract.js";
6
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
7
+ export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
8
+ export declare const SESSION_HEADER = "x-segmentstream-agent-session";
9
+ /** Both temporary attachments and stored files require the runtime and its paired member. */
10
+ export declare function authenticateAgentCaller(input: {
11
+ readonly request: HttpServerRequest.HttpServerRequest;
12
+ readonly agents: AgentStore;
13
+ readonly verify: RuntimeTokenVerifier;
14
+ }): Effect.Effect<{
15
+ readonly ok: false;
16
+ readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
17
+ } | {
18
+ readonly ok: true;
19
+ readonly caller: AttachmentCaller;
20
+ }, never, RequestId | HttpServerRequest.ParsedSearchParams>;
@@ -0,0 +1,48 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import { HttpServerRequest } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
5
+ import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
6
+ export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
7
+ export const SESSION_HEADER = "x-segmentstream-agent-session";
8
+ const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
9
+ const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
10
+ /** Both temporary attachments and stored files require the runtime and its paired member. */
11
+ export function authenticateAgentCaller(input) {
12
+ return Effect.gen(function* () {
13
+ const { request, agents, verify } = input;
14
+ const requestId = yield* RequestId;
15
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
16
+ const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
17
+ const token = Redacted.value(bearer);
18
+ const verified = token === "" ? undefined : yield* Effect.promise(() => verify(token));
19
+ const denied = (response) => ({ ok: false, response: toServerResponse(response) });
20
+ switch (verified?.status) {
21
+ case "valid":
22
+ break;
23
+ case "invalid":
24
+ yield* logRuntimeRejection(verified.reason);
25
+ return denied(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
26
+ case "unavailable":
27
+ return denied(unavailable(requestId));
28
+ default:
29
+ return denied(forbidden(requestId, "Only the app's agent calls this route."));
30
+ }
31
+ const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
32
+ const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
33
+ const session = request.headers[SESSION_HEADER]?.trim() ?? "";
34
+ const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
35
+ if (member === undefined) {
36
+ return denied(forbidden(requestId, "The member is inactive or has not paired with the agent."));
37
+ }
38
+ if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
39
+ return denied(forbidden(requestId, "The agent names the chat and session of every request."));
40
+ }
41
+ const caller = {
42
+ principal: member,
43
+ conversation,
44
+ ...(session === "" ? {} : { sessionId: session }),
45
+ };
46
+ return { ok: true, caller };
47
+ });
48
+ }
@@ -0,0 +1,12 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { RequestId } from "../errors.js";
4
+ import type { ApiPlan } from "../plan.js";
5
+ import { OntologyBackend } from "../runtime-contract.js";
6
+ import type { RuntimeTokenVerifier } from "../runtime-identity.js";
7
+ export declare const FILES_PATH = "/api/ontology/agent/files";
8
+ /** A short-lived download grant, issued only after the runtime's member may read the file. */
9
+ export declare function filesLayer(options: {
10
+ readonly plan: ApiPlan;
11
+ readonly verifyRuntime: RuntimeTokenVerifier | undefined;
12
+ }): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
@@ -0,0 +1,69 @@
1
+ import { Cause, Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
3
+ import { resolveFileRecord } from "../../runtime/files.js";
4
+ import { isUuid } from "../../query.js";
5
+ import { RequestId, failureResponse, notFound, toServerResponse } from "../errors.js";
6
+ import { OntologyBackend } from "../runtime-contract.js";
7
+ import { authenticateAgentCaller } from "./authentication.js";
8
+ import { AGENT_PATH } from "./server.js";
9
+ export const FILES_PATH = `${AGENT_PATH}/files`;
10
+ /** A short-lived download grant, issued only after the runtime's member may read the file. */
11
+ export function filesLayer(options) {
12
+ return Layer.effectDiscard(Effect.gen(function* () {
13
+ const router = yield* HttpRouter.HttpRouter;
14
+ const backend = yield* OntologyBackend;
15
+ const { blobs, agents } = backend;
16
+ const verify = options.verifyRuntime;
17
+ if (blobs === undefined || agents === undefined || verify === undefined) {
18
+ return;
19
+ }
20
+ const read = (request) => Effect.gen(function* () {
21
+ const requestId = yield* RequestId;
22
+ const authenticated = yield* authenticateAgentCaller({ request, agents, verify });
23
+ if (!authenticated.ok) {
24
+ return authenticated.response;
25
+ }
26
+ const params = yield* HttpRouter.params;
27
+ const id = params.id ?? "";
28
+ if (!isUuid(id)) {
29
+ return toServerResponse(notFound(requestId, "File record not found."));
30
+ }
31
+ const resolved = yield* resolveFileRecord({
32
+ manifest: options.plan.ontology.manifest,
33
+ reads: backend,
34
+ principal: authenticated.caller.principal,
35
+ reference: {
36
+ entity: params.entity ?? "",
37
+ id,
38
+ ...(params.property === undefined ? {} : { property: params.property }),
39
+ },
40
+ });
41
+ // Pin the revision read above. download rechecks live permissions in its transaction.
42
+ const download = yield* blobs.download(resolved.entity, resolved.id, resolved.property, { revisionId: resolved.file.id }, authenticated.caller.principal);
43
+ return HttpServerResponse.jsonUnsafe({
44
+ file: {
45
+ id,
46
+ entity: resolved.entity,
47
+ property: resolved.property,
48
+ filename: resolved.file.filename,
49
+ content_type: resolved.file.contentType,
50
+ size: resolved.file.size,
51
+ },
52
+ download: {
53
+ url: download.url,
54
+ method: download.method,
55
+ headers: download.headers,
56
+ expires_at: download.expiresAt,
57
+ },
58
+ }, { headers: { "cache-control": "no-store" } });
59
+ }).pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.map(RequestId, (requestId) => HttpServerResponse.jsonUnsafe({
60
+ error: "unsupported",
61
+ detail: error.message,
62
+ request_id: requestId,
63
+ }, { status: 422 }))), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
64
+ ? Effect.failCause(cause)
65
+ : Effect.flatMap(RequestId, (requestId) => failureResponse(cause, requestId))));
66
+ yield* router.add("GET", `${FILES_PATH}/:entity/:id`, read);
67
+ yield* router.add("GET", `${FILES_PATH}/:entity/:id/:property`, read);
68
+ }));
69
+ }
@@ -170,7 +170,13 @@ function seeChat(store) {
170
170
  if (Result.isFailure(decoded)) {
171
171
  return invalidBody(requestId, schemaIssues(decoded.failure));
172
172
  }
173
- return json(yield* store.seeChat(decoded.success));
173
+ const chat = yield* store.seeChat(decoded.success);
174
+ yield* Effect.logInfo("ontology.agent.chat_reported").pipe(Effect.annotateLogs({
175
+ requestId,
176
+ provider: decoded.success.provider,
177
+ chatId: decoded.success.externalId,
178
+ }));
179
+ return json(chat);
174
180
  });
175
181
  }
176
182
  /** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
@@ -5,6 +5,7 @@ import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-au
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
7
  import { attachmentsLayer } from "./agent/attachments.js";
8
+ import { filesLayer } from "./agent/files.js";
8
9
  import { platformConversations } from "./agent/conversations.js";
9
10
  import { conversationRoutes } from "./agent/conversation-routes.js";
10
11
  import { deployedAgentChannel } from "./agent/channel.js";
@@ -130,7 +131,7 @@ export function createOntologyService(ontology, config) {
130
131
  verifyToken,
131
132
  verifyRuntime,
132
133
  });
133
- const application = Layer.mergeAll(routes, mcp, agent, attachments, conversations === undefined
134
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
134
135
  ? Layer.empty
135
136
  : conversationRoutes({
136
137
  transport: conversations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",