@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 +25 -1
- package/dist/agent-chat.d.ts +3 -0
- package/dist/agent-chat.js +25 -1
- package/dist/runtime/files.d.ts +39 -0
- package/dist/runtime/files.js +44 -0
- package/dist/runtime/pipeline.js +4 -1
- package/dist/service/agent/attachments.d.ts +2 -5
- package/dist/service/agent/attachments.js +13 -42
- package/dist/service/agent/authentication.d.ts +20 -0
- package/dist/service/agent/authentication.js +48 -0
- package/dist/service/agent/files.d.ts +12 -0
- package/dist/service/agent/files.js +69 -0
- package/dist/service/agent/server.js +7 -1
- package/dist/service/service.js +2 -1
- package/package.json +1 -1
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
|
package/dist/agent-chat.d.ts
CHANGED
|
@@ -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
|
}>, {
|
package/dist/agent-chat.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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,
|
|
2
|
-
import { HttpRouter,
|
|
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
|
|
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 {
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/service/service.js
CHANGED
|
@@ -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.
|
|
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",
|