@segmentstream-cloud/ontology 0.25.0 → 0.25.1

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.
@@ -13,9 +13,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
13
13
  /**
14
14
  * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
15
15
  * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
16
- * while they are active and paired. The chat and the agent session come from the agent's runtime in
16
+ * while they are active. The chat and the agent session come from the agent's runtime in
17
17
  * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
18
- * tool arguments; every operation is confined to that member, chat and session.
18
+ * tool arguments. Storage checks current group participation or chat administration; private files
19
+ * remain confined to their sender and session.
19
20
  */
20
21
  export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
21
22
  export interface AttachmentRoutesOptions {
@@ -12,9 +12,10 @@ 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`
15
- * while they are active and paired. The chat and the agent session come from the agent's runtime in
15
+ * while they are active. The chat and the agent session come from the agent's runtime in
16
16
  * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
17
- * tool arguments; every operation is confined to that member, chat and session.
17
+ * tool arguments. Storage checks current group participation or chat administration; private files
18
+ * remain confined to their sender and session.
18
19
  */
19
20
  export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
20
21
  const NO_STORE = { "cache-control": "no-store" };
@@ -111,6 +112,7 @@ export function attachmentsLayer(options) {
111
112
  request,
112
113
  agents,
113
114
  verify: verifyRuntime,
115
+ allowUnpairedMember: true,
114
116
  });
115
117
  return authenticated.ok
116
118
  ? yield* answer(request, authenticated.caller, attachments)
@@ -6,11 +6,13 @@ import type { AgentStore } from "../runtime-contract.js";
6
6
  import { type RuntimeTokenVerifier } from "../runtime-identity.js";
7
7
  export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
8
8
  export declare const SESSION_HEADER = "x-segmentstream-agent-session";
9
- /** Both temporary attachments and stored files require the runtime and its paired member. */
9
+ /** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
10
10
  export declare function authenticateAgentCaller(input: {
11
11
  readonly request: HttpServerRequest.HttpServerRequest;
12
12
  readonly agents: AgentStore;
13
13
  readonly verify: RuntimeTokenVerifier;
14
+ /** Temporary attachments authorize active members against the chat registry, without a pairing. */
15
+ readonly allowUnpairedMember?: boolean;
14
16
  }): Effect.Effect<{
15
17
  readonly ok: false;
16
18
  readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
@@ -1,13 +1,13 @@
1
1
  import { Effect, Redacted } from "effect";
2
2
  import { HttpServerRequest } from "effect/unstable/http";
3
3
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
- import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
4
+ import { RequestId, forbidden, notFound, toServerResponse, unauthorized, unavailable, } from "../errors.js";
5
5
  import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
6
6
  export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
7
7
  export const SESSION_HEADER = "x-segmentstream-agent-session";
8
8
  const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
9
9
  const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
10
- /** Both temporary attachments and stored files require the runtime and its paired member. */
10
+ /** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
11
11
  export function authenticateAgentCaller(input) {
12
12
  return Effect.gen(function* () {
13
13
  const { request, agents, verify } = input;
@@ -31,9 +31,15 @@ export function authenticateAgentCaller(input) {
31
31
  const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
32
32
  const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
33
33
  const session = request.headers[SESSION_HEADER]?.trim() ?? "";
34
- const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
34
+ const member = actAs === ""
35
+ ? undefined
36
+ : input.allowUnpairedMember
37
+ ? (yield* agents.member(actAs))?.principal
38
+ : yield* agents.actingMember(actAs);
35
39
  if (member === undefined) {
36
- return denied(forbidden(requestId, "The member is inactive or has not paired with the agent."));
40
+ return denied(input.allowUnpairedMember
41
+ ? notFound(requestId, "Attachment not found.")
42
+ : forbidden(requestId, "The member is inactive or has not paired with the agent."));
37
43
  }
38
44
  if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
39
45
  return denied(forbidden(requestId, "The agent names the chat and session of every request."));
@@ -6,11 +6,12 @@ import type { Principal } from "../runtime.js";
6
6
  import type { BlobProvider, TransferGrant } from "./provider.js";
7
7
  /**
8
8
  * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
9
- * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
10
- * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
11
- * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
12
- * promote it into a record through an action, which adopts the same stored object without copying
13
- * it. Every operation re-derives the owner, chat and session from the caller, never from input.
9
+ * `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
10
+ * members; private files belong to their sender and the agent session that first reads them.
11
+ * Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
12
+ * discard it, or promote it into a record through an action, which adopts the same stored object
13
+ * without copying it. Every operation derives chat access from the caller and registry, never from
14
+ * tool input.
14
15
  */
15
16
  /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
16
17
  export declare const MAX_ATTACHMENT_BYTES: number;
@@ -25,8 +26,8 @@ export interface AttachmentCaller {
25
26
  /** The chat the attachment arrived in, such as `telegram:12345`. */
26
27
  readonly conversation: string;
27
28
  /**
28
- * The agent session the request comes from. The first session that reads an attachment owns it;
29
- * other sessions of the same chat, such as one after a reset, never see it.
29
+ * The agent session the request comes from. Private attachments stay bound to the first session
30
+ * that reads them. Group attachments are shared across authorized sessions in the same chat.
30
31
  */
31
32
  readonly sessionId?: string;
32
33
  }
@@ -9,11 +9,12 @@ import { authorizeBlobOperation } from "./authorization.js";
9
9
  import { ATTACHMENTS_TABLE } from "./tables.js";
10
10
  /**
11
11
  * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
12
- * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
13
- * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
14
- * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
15
- * promote it into a record through an action, which adopts the same stored object without copying
16
- * it. Every operation re-derives the owner, chat and session from the caller, never from input.
12
+ * `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
13
+ * members; private files belong to their sender and the agent session that first reads them.
14
+ * Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
15
+ * discard it, or promote it into a record through an action, which adopts the same stored object
16
+ * without copying it. Every operation derives chat access from the caller and registry, never from
17
+ * tool input.
17
18
  */
18
19
  /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
19
20
  export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
@@ -102,9 +103,34 @@ export function attachmentRuntime(ontology, sql, provider) {
102
103
  const binding = provider.binding;
103
104
  const missing = (id) => new NotFound({ entity: "attachment", id, message: "Attachment not found." });
104
105
  const unavailable = (message) => new Conflict({ entity: "attachment", message });
106
+ /** The registry contains groups only. Neither a tool argument nor a caller's roles name a group. */
107
+ const accessScope = (caller) => Effect.gen(function* () {
108
+ const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [caller.principal.id]);
109
+ if (member?.kind !== "member" || !member.active) {
110
+ return "denied";
111
+ }
112
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
113
+ const separator = caller.conversation.indexOf(":");
114
+ const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
115
+ WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
116
+ if (chat === undefined) {
117
+ return "private";
118
+ }
119
+ const [participant] = yield* sql.unsafe(`SELECT 1 FROM "agent_chat_members"
120
+ WHERE agent_chat_id = $1::uuid AND user_id = $2::uuid FOR SHARE`, [chat.id, caller.principal.id]);
121
+ if (participant !== undefined) {
122
+ return "group";
123
+ }
124
+ // App roles are authored by the workspace. Chat management grants its administrator access;
125
+ // re-read the roles here rather than trusting permissions cached on the principal.
126
+ const administrator = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
127
+ "agent_chat:update",
128
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
129
+ return administrator ? "group" : "denied";
130
+ });
105
131
  /**
106
- * The caller's attachment, locked. Another member's, another chat's or another session's is not
107
- * found: it does not exist for this caller. The first session that loads it owns it.
132
+ * The caller's attachment, locked. Group access follows current chat membership. Private access
133
+ * requires the sender and their session. Source transfers always require the original sender.
108
134
  */
109
135
  const load = (id, caller,
110
136
  /** `source`: the webhook redelivering the file it stored, before or after a session read it. */
@@ -112,23 +138,37 @@ export function attachmentRuntime(ontology, sql, provider) {
112
138
  if (!/^[0-9a-f-]{36}$/i.test(id)) {
113
139
  return yield* missing(id);
114
140
  }
115
- const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND a.owner_id = $2::uuid AND a.conversation = $3
116
- AND a.branch_id = $4 FOR UPDATE OF a, b`, [id, caller.principal.id, caller.conversation, binding.branchId]);
141
+ const access = yield* accessScope(caller);
142
+ if (access === "denied") {
143
+ return yield* missing(id);
144
+ }
145
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND (a.owner_id = $2::uuid OR $5) AND a.conversation = $3
146
+ AND a.branch_id = $4 FOR UPDATE OF a, b`, [
147
+ id,
148
+ caller.principal.id,
149
+ caller.conversation,
150
+ binding.branchId,
151
+ access === "group" && scope === "session",
152
+ ]);
117
153
  const row = rows[0];
118
154
  if (!row) {
119
155
  return yield* missing(id);
120
156
  }
121
- if (scope === "session" && row.session_id !== null && row.session_id !== caller.sessionId) {
157
+ if (access === "private" &&
158
+ scope === "session" &&
159
+ row.session_id !== null &&
160
+ row.session_id !== caller.sessionId) {
122
161
  return yield* missing(id);
123
162
  }
124
- if (scope === "session" && row.session_id === null && caller.sessionId !== undefined) {
163
+ if (access === "private" &&
164
+ scope === "session" &&
165
+ row.session_id === null &&
166
+ caller.sessionId !== undefined) {
125
167
  yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET session_id = $1 WHERE id = $2::uuid`, [
126
168
  caller.sessionId,
127
169
  row.id,
128
170
  ]);
129
171
  }
130
- // The member must still be active; a deactivated member's agent reads nothing.
131
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
132
172
  return row;
133
173
  });
134
174
  /**
@@ -203,7 +243,7 @@ export function attachmentRuntime(ontology, sql, provider) {
203
243
  input.size,
204
244
  input.sha256,
205
245
  ]);
206
- return yield* load(id, caller);
246
+ return yield* load(id, caller, "source");
207
247
  }));
208
248
  if (reserved.state !== "received" || reserved.expired) {
209
249
  return { attachment: view(reserved) };
@@ -232,7 +272,7 @@ export function attachmentRuntime(ontology, sql, provider) {
232
272
  });
233
273
  /** Completes the transfer, then verifies the stored bytes against the declared size and digest. */
234
274
  const complete = (id, caller) => Effect.gen(function* () {
235
- const row = yield* sql.withTransaction(load(id, caller));
275
+ const row = yield* sql.withTransaction(load(id, caller, "source"));
236
276
  if (row.state !== "received") {
237
277
  return READABLE.has(row.state) && !row.expired
238
278
  ? view(row)
@@ -267,16 +307,25 @@ export function attachmentRuntime(ontology, sql, provider) {
267
307
  return yield* sql.withTransaction(Effect.gen(function* () {
268
308
  yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'ready'
269
309
  WHERE id = $1::uuid AND state = 'received'`, [row.id]);
270
- return view(yield* load(row.id, caller));
310
+ return view(yield* load(row.id, caller, "source"));
271
311
  }));
272
312
  });
273
- /** The caller's attachments in this chat and session that are still readable, newest first. */
313
+ /** Accessible attachments in this chat that are still readable, newest first. */
274
314
  const list = (caller) => sql.withTransaction(Effect.gen(function* () {
275
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
276
- const rows = yield* sql.unsafe(`${SELECT} WHERE a.owner_id = $1::uuid AND a.conversation = $2 AND a.branch_id = $3
315
+ const access = yield* accessScope(caller);
316
+ if (access === "denied") {
317
+ return [];
318
+ }
319
+ const rows = yield* sql.unsafe(`${SELECT} WHERE (a.owner_id = $1::uuid OR $5) AND a.conversation = $2 AND a.branch_id = $3
277
320
  AND a.state IN ('ready', 'promoted') AND a.expires_at > now()
278
- AND (a.session_id IS NULL OR a.session_id = $4)
279
- ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [caller.principal.id, caller.conversation, binding.branchId, caller.sessionId ?? null]);
321
+ AND ($5 OR a.session_id IS NULL OR a.session_id = $4)
322
+ ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
323
+ caller.principal.id,
324
+ caller.conversation,
325
+ binding.branchId,
326
+ caller.sessionId ?? null,
327
+ access === "group",
328
+ ]);
280
329
  return rows.map(view);
281
330
  }));
282
331
  /**
@@ -351,6 +400,7 @@ export function attachmentRuntime(ontology, sql, provider) {
351
400
  const qualified = `${target.entity}.${target.action}`;
352
401
  const prepared = yield* sql.withTransaction(Effect.gen(function* () {
353
402
  const row = yield* load(id, caller);
403
+ yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
354
404
  if (row.state === "promoted") {
355
405
  return { kind: "done", row };
356
406
  }
@@ -365,11 +415,21 @@ export function attachmentRuntime(ontology, sql, provider) {
365
415
  `${row.content_type}, ${row.size} bytes.`,
366
416
  });
367
417
  }
368
- yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
369
418
  const [earlier] = yield* sql.unsafe(`SELECT id::text, promotion FROM "${ATTACHMENTS_TABLE}"
370
419
  WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
371
420
  AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
372
- ORDER BY created_at LIMIT 1`, [row.sha256, binding.branchId, row.id, qualified]);
421
+ AND conversation = $5 AND (owner_id = $6::uuid OR $7)
422
+ AND ($7 OR session_id IS NULL OR session_id = $8)
423
+ ORDER BY created_at LIMIT 1`, [
424
+ row.sha256,
425
+ binding.branchId,
426
+ row.id,
427
+ qualified,
428
+ caller.conversation,
429
+ caller.principal.id,
430
+ (yield* accessScope(caller)) === "group",
431
+ caller.sessionId ?? null,
432
+ ]);
373
433
  if (earlier) {
374
434
  const promotion = {
375
435
  ...earlier.promotion,
@@ -5,6 +5,7 @@ export declare const ATTACHMENTS_TABLE = "_agent_attachments";
5
5
  /**
6
6
  * A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
7
7
  * a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
8
- * and the agent session that received them, and expires 24 hours after receipt unless promoted.
8
+ * and, for private chats, the first agent session that reads them. Group access follows the chat
9
+ * registry's current members. Attachments expire 24 hours after receipt unless promoted.
9
10
  */
10
11
  export declare const ATTACHMENT_TABLES_DDL: ReadonlyArray<string>;
@@ -58,7 +58,8 @@ export const ATTACHMENTS_TABLE = "_agent_attachments";
58
58
  /**
59
59
  * A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
60
60
  * a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
61
- * and the agent session that received them, and expires 24 hours after receipt unless promoted.
61
+ * and, for private chats, the first agent session that reads them. Group access follows the chat
62
+ * registry's current members. Attachments expire 24 hours after receipt unless promoted.
62
63
  */
63
64
  export const ATTACHMENT_TABLES_DDL = [
64
65
  `CREATE TABLE IF NOT EXISTS "${ATTACHMENTS_TABLE}" (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
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",