@segmentstream-cloud/ontology 0.25.1 → 0.26.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.
@@ -0,0 +1,165 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { CurrentPrincipal } from "./authentication.js";
3
+ import { RequestId } from "./errors.js";
4
+ import type { Operations } from "./operations.js";
5
+ import type { ApiPlan } from "./plan.js";
6
+ import type { OntologyBackend } from "./runtime-contract.js";
7
+ export declare const AttachmentFilters: Schema.Struct<{
8
+ readonly conversation: Schema.optionalKey<Schema.String>;
9
+ readonly sender_id: Schema.optionalKey<Schema.String>;
10
+ readonly from: Schema.optionalKey<Schema.String>;
11
+ readonly to: Schema.optionalKey<Schema.String>;
12
+ readonly before: Schema.optionalKey<Schema.String>;
13
+ readonly limit: Schema.optionalKey<Schema.Int>;
14
+ }>;
15
+ export declare const MEMBER_FILE_SCHEMAS: {
16
+ "attachments-list": Schema.Struct<{
17
+ readonly conversation: Schema.optionalKey<Schema.String>;
18
+ readonly sender_id: Schema.optionalKey<Schema.String>;
19
+ readonly from: Schema.optionalKey<Schema.String>;
20
+ readonly to: Schema.optionalKey<Schema.String>;
21
+ readonly before: Schema.optionalKey<Schema.String>;
22
+ readonly limit: Schema.optionalKey<Schema.Int>;
23
+ }>;
24
+ "attachments-get": Schema.Struct<{
25
+ readonly id: Schema.String;
26
+ }>;
27
+ "attachments-download": Schema.Struct<{
28
+ readonly id: Schema.String;
29
+ }>;
30
+ "attachments-promote": Schema.Struct<{
31
+ readonly id: Schema.String;
32
+ readonly entity: Schema.String;
33
+ readonly action: Schema.String;
34
+ readonly input: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
35
+ readonly field: Schema.optionalKey<Schema.String>;
36
+ readonly record_id: Schema.optionalKey<Schema.String>;
37
+ }>;
38
+ "files-download": Schema.Struct<{
39
+ readonly entity: Schema.String;
40
+ readonly id: Schema.String;
41
+ readonly property: Schema.optionalKey<Schema.String>;
42
+ }>;
43
+ };
44
+ export type MemberFileOperation = keyof typeof MEMBER_FILE_SCHEMAS;
45
+ export declare function grantForWire(grant: {
46
+ url: string;
47
+ method: string;
48
+ headers: Readonly<Record<string, string>>;
49
+ expiresAt: string;
50
+ }): {
51
+ url: string;
52
+ method: string;
53
+ headers: Readonly<Record<string, string>>;
54
+ expires_at: string;
55
+ };
56
+ export declare function attachmentForWire(attachment: import("../storage/attachments.js").AttachmentView): {
57
+ id: string;
58
+ filename: string;
59
+ content_type: string;
60
+ size: number;
61
+ sha256: string;
62
+ state: string;
63
+ created_at: string;
64
+ expires_at: string;
65
+ promotion: unknown;
66
+ conversation: string | undefined;
67
+ sender_id: string | undefined;
68
+ };
69
+ /** The member REST and MCP surfaces run the same runtime operations and action pipeline. */
70
+ export declare function memberFileOperations(backend: OntologyBackend["Service"], plan: ApiPlan, operations: Operations): (kind: MemberFileOperation, args: unknown, identityToken?: string) => Effect.Effect<{
71
+ download: {
72
+ url: string;
73
+ method: string;
74
+ headers: Readonly<Record<string, string>>;
75
+ expires_at: string;
76
+ };
77
+ file: {
78
+ id: string;
79
+ entity: string;
80
+ property: string;
81
+ revision_id: string;
82
+ filename: string;
83
+ content_type: string;
84
+ size: number;
85
+ sha256: string;
86
+ };
87
+ attachments?: undefined;
88
+ nextCursor?: undefined;
89
+ attachment?: undefined;
90
+ } | {
91
+ attachments: {
92
+ id: string;
93
+ filename: string;
94
+ content_type: string;
95
+ size: number;
96
+ sha256: string;
97
+ state: string;
98
+ created_at: string;
99
+ expires_at: string;
100
+ promotion: unknown;
101
+ conversation: string | undefined;
102
+ sender_id: string | undefined;
103
+ }[];
104
+ nextCursor: string | null;
105
+ attachment?: undefined;
106
+ download?: undefined;
107
+ } | {
108
+ attachment: {
109
+ id: string;
110
+ filename: string;
111
+ content_type: string;
112
+ size: number;
113
+ sha256: string;
114
+ state: string;
115
+ created_at: string;
116
+ expires_at: string;
117
+ promotion: unknown;
118
+ conversation: string | undefined;
119
+ sender_id: string | undefined;
120
+ };
121
+ deduplicated: boolean;
122
+ result: {} | null;
123
+ attachments?: undefined;
124
+ nextCursor?: undefined;
125
+ download?: undefined;
126
+ } | {
127
+ attachment: {
128
+ id: string;
129
+ filename: string;
130
+ content_type: string;
131
+ size: number;
132
+ sha256: string;
133
+ state: string;
134
+ created_at: string;
135
+ expires_at: string;
136
+ promotion: unknown;
137
+ conversation: string | undefined;
138
+ sender_id: string | undefined;
139
+ };
140
+ attachments?: undefined;
141
+ nextCursor?: undefined;
142
+ download?: undefined;
143
+ } | {
144
+ attachment: {
145
+ id: string;
146
+ filename: string;
147
+ content_type: string;
148
+ size: number;
149
+ sha256: string;
150
+ state: string;
151
+ created_at: string;
152
+ expires_at: string;
153
+ promotion: unknown;
154
+ conversation: string | undefined;
155
+ sender_id: string | undefined;
156
+ };
157
+ download: {
158
+ url: string;
159
+ method: string;
160
+ headers: Readonly<Record<string, string>>;
161
+ expires_at: string;
162
+ };
163
+ attachments?: undefined;
164
+ nextCursor?: undefined;
165
+ }, unknown, RequestId | CurrentPrincipal>;
@@ -0,0 +1,145 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { NotFound } from "../errors.js";
3
+ import { schemaIssues } from "../runtime/validation.js";
4
+ import { CurrentPrincipal } from "./authentication.js";
5
+ import { InvalidRequestBody, RequestId } from "./errors.js";
6
+ const Id = Schema.String.check(Schema.isUUID());
7
+ const DateTime = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/));
8
+ export const AttachmentFilters = Schema.Struct({
9
+ conversation: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200))),
10
+ sender_id: Schema.optionalKey(Id),
11
+ from: Schema.optionalKey(DateTime),
12
+ to: Schema.optionalKey(DateTime),
13
+ before: Schema.optionalKey(Id),
14
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))),
15
+ });
16
+ const AttachmentId = Schema.Struct({ id: Id });
17
+ const Promotion = Schema.Struct({
18
+ id: Id,
19
+ entity: Schema.String,
20
+ action: Schema.String,
21
+ input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
22
+ field: Schema.optionalKey(Schema.String),
23
+ record_id: Schema.optionalKey(Id),
24
+ });
25
+ const File = Schema.Struct({
26
+ entity: Schema.String,
27
+ id: Id,
28
+ property: Schema.optionalKey(Schema.String),
29
+ });
30
+ export const MEMBER_FILE_SCHEMAS = {
31
+ "attachments-list": AttachmentFilters,
32
+ "attachments-get": AttachmentId,
33
+ "attachments-download": AttachmentId,
34
+ "attachments-promote": Promotion,
35
+ "files-download": File,
36
+ };
37
+ export function grantForWire(grant) {
38
+ return {
39
+ url: grant.url,
40
+ method: grant.method,
41
+ headers: grant.headers,
42
+ expires_at: grant.expiresAt,
43
+ };
44
+ }
45
+ export function attachmentForWire(attachment) {
46
+ return {
47
+ id: attachment.id,
48
+ filename: attachment.filename,
49
+ content_type: attachment.contentType,
50
+ size: attachment.size,
51
+ sha256: attachment.sha256,
52
+ state: attachment.state,
53
+ created_at: attachment.createdAt,
54
+ expires_at: attachment.expiresAt,
55
+ promotion: attachment.promotion,
56
+ conversation: attachment.conversation,
57
+ sender_id: attachment.senderId,
58
+ };
59
+ }
60
+ function decode(schema, input) {
61
+ return Schema.decodeUnknownEffect(schema)(input, { onExcessProperty: "error" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error) })));
62
+ }
63
+ /** The member REST and MCP surfaces run the same runtime operations and action pipeline. */
64
+ export function memberFileOperations(backend, plan, operations) {
65
+ return (kind, args, identityToken) => Effect.gen(function* () {
66
+ const principal = yield* CurrentPrincipal;
67
+ const requestId = yield* RequestId;
68
+ if (kind === "files-download") {
69
+ const input = yield* decode(File, args);
70
+ if (!backend.blobs) {
71
+ return yield* new NotFound({
72
+ entity: input.entity,
73
+ id: input.id,
74
+ message: "File storage is unavailable.",
75
+ });
76
+ }
77
+ const result = yield* backend.blobs.downloadFile(input.entity, input.id, input.property ?? "content", {}, principal);
78
+ return { ...result, download: grantForWire(result.download) };
79
+ }
80
+ if (principal.kind !== "member") {
81
+ return yield* new NotFound({
82
+ entity: "attachment",
83
+ id: "",
84
+ message: "Attachment not found.",
85
+ });
86
+ }
87
+ const attachments = backend.attachments;
88
+ if (!attachments) {
89
+ return yield* new NotFound({
90
+ entity: "attachment",
91
+ id: "",
92
+ message: "Attachment storage is unavailable.",
93
+ });
94
+ }
95
+ if (kind === "attachments-list") {
96
+ const input = yield* decode(AttachmentFilters, args);
97
+ if ((input.from !== undefined && !Number.isFinite(Date.parse(input.from))) ||
98
+ (input.to !== undefined && !Number.isFinite(Date.parse(input.to))) ||
99
+ (input.from !== undefined &&
100
+ input.to !== undefined &&
101
+ Date.parse(input.from) > Date.parse(input.to))) {
102
+ return yield* new InvalidRequestBody({ reason: "invalid_request", issues: [] });
103
+ }
104
+ const { sender_id, ...rest } = input;
105
+ const result = yield* attachments.listForMember({ ...rest, ...(sender_id === undefined ? {} : { senderId: sender_id }) }, principal);
106
+ return {
107
+ attachments: result.attachments.map(attachmentForWire),
108
+ nextCursor: result.nextCursor,
109
+ };
110
+ }
111
+ if (kind === "attachments-promote") {
112
+ const input = yield* decode(Promotion, args);
113
+ const promoted = yield* attachments.promoteForMember(input.id, {
114
+ entity: input.entity,
115
+ action: input.action,
116
+ input: input.input ?? {},
117
+ ...(input.field === undefined ? {} : { field: input.field }),
118
+ ...(input.record_id === undefined ? {} : { recordId: input.record_id }),
119
+ }, principal, (call) => Effect.gen(function* () {
120
+ const entity = plan.entities.find((item) => item.name === call.entity);
121
+ const action = entity?.actions.find((item) => item.name === call.action);
122
+ if (!entity || !action) {
123
+ return yield* Effect.die(new Error("The promotion action was not planned."));
124
+ }
125
+ const answer = yield* operations.runAction(entity, action, {
126
+ input: call.input,
127
+ options: call.options,
128
+ validateOnly: false,
129
+ identityToken,
130
+ });
131
+ return { result: answer.result, edits: answer.edits };
132
+ }).pipe(Effect.provideService(CurrentPrincipal, principal), Effect.provideService(RequestId, requestId)));
133
+ return { ...promoted, attachment: attachmentForWire(promoted.attachment) };
134
+ }
135
+ const { id } = yield* decode(AttachmentId, args);
136
+ if (kind === "attachments-get") {
137
+ return { attachment: attachmentForWire(yield* attachments.getForMember(id, principal)) };
138
+ }
139
+ const result = yield* attachments.readForMember(id, principal);
140
+ return {
141
+ attachment: attachmentForWire(result.attachment),
142
+ download: grantForWire(result.download),
143
+ };
144
+ });
145
+ }
@@ -1,6 +1,7 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
5
6
  import { isValueMatched, OntologyManifestWire } from "../manifest.js";
6
7
  import { entityQuerySchemas, queryLinks } from "../query.js";
@@ -510,14 +511,17 @@ export const ErrorSchemas = {
510
511
  notFound: errorBody("NotFound", 404, ["not_found"], {
511
512
  entity: Schema.optionalKey(Schema.String),
512
513
  id: Schema.optionalKey(Schema.String),
514
+ batch: Schema.optionalKey(BatchErrorDetails),
513
515
  }),
514
516
  conflict: errorBody("Conflict", 409, ["conflict"], {
515
517
  entity: Schema.optionalKey(Schema.String),
516
518
  property: Schema.optionalKey(Schema.String),
519
+ batch: Schema.optionalKey(BatchErrorDetails),
517
520
  }),
518
521
  unsupportedMediaType: errorBody("UnsupportedMediaType", 415, ["unsupported_media_type"]),
519
522
  unprocessable: errorBody("Unprocessable", 422, ["invalid_input", "action_rejected"], {
520
523
  issues: Schema.Array(ValidationIssue),
524
+ batch: Schema.optionalKey(BatchErrorDetails),
521
525
  }),
522
526
  rateLimited: errorBody("RateLimited", 429, ["rate_limited"]),
523
527
  internal: errorBody("InternalError", 500, ["internal_error"]),
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { memberFileRoutes } from "./member-file-routes.js";
7
8
  import { attachmentsLayer } from "./agent/attachments.js";
8
9
  import { filesLayer } from "./agent/files.js";
9
10
  import { platformConversations } from "./agent/conversations.js";
@@ -131,7 +132,15 @@ export function createOntologyService(ontology, config) {
131
132
  verifyToken,
132
133
  verifyRuntime,
133
134
  });
134
- const application = Layer.mergeAll(routes, mcp, agent, attachments, filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
135
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, memberFileRoutes({
136
+ plan: built.plan,
137
+ hooks,
138
+ connections,
139
+ replicate,
140
+ agentChannel,
141
+ verifyToken,
142
+ exchangeSession,
143
+ }), filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
135
144
  ? Layer.empty
136
145
  : conversationRoutes({
137
146
  transport: conversations,
@@ -30,6 +30,8 @@ export interface AttachmentCaller {
30
30
  * that reads them. Group attachments are shared across authorized sessions in the same chat.
31
31
  */
32
32
  readonly sessionId?: string;
33
+ /** Trusted user boundary; never accepted from agent tool arguments. */
34
+ readonly access?: "member";
33
35
  }
34
36
  export interface ReceiveAttachment {
35
37
  readonly source: (typeof ATTACHMENT_SOURCES)[number];
@@ -50,6 +52,16 @@ export interface AttachmentView {
50
52
  readonly createdAt: string;
51
53
  readonly expiresAt: string;
52
54
  readonly promotion: unknown;
55
+ readonly conversation?: string;
56
+ readonly senderId?: string;
57
+ }
58
+ export interface AttachmentSearch {
59
+ readonly conversation?: string;
60
+ readonly senderId?: string;
61
+ readonly from?: string;
62
+ readonly to?: string;
63
+ readonly before?: string;
64
+ readonly limit?: number;
53
65
  }
54
66
  export interface PromotionTarget {
55
67
  /** The entity whose action adopts the file, such as `document`. */
@@ -103,6 +115,20 @@ export declare function attachmentRuntime(ontology: Ontology, sql: SqlClient.Sql
103
115
  deduplicated: boolean;
104
116
  result: {} | null;
105
117
  }, unknown, never>;
118
+ listForMember: (filters: AttachmentSearch, principal: Principal) => Effect.Effect<{
119
+ attachments: AttachmentView[];
120
+ nextCursor: string | null;
121
+ }, PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
122
+ getForMember: (id: string, principal: Principal) => Effect.Effect<AttachmentView, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
123
+ readForMember: (id: string, principal: Principal) => Effect.Effect<{
124
+ attachment: AttachmentView;
125
+ download: TransferGrant;
126
+ }, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
127
+ promoteForMember: (id: string, target: PromotionTarget, principal: Principal, run: PromotionRunner) => Effect.Effect<{
128
+ attachment: AttachmentView;
129
+ deduplicated: boolean;
130
+ result: {} | null;
131
+ }, unknown, never>;
106
132
  };
107
133
  export type AttachmentRuntime = ReturnType<typeof attachmentRuntime>;
108
134
  export type AttachmentFailure = ActionRejected | Conflict | NotFound | PermissionDenied;
@@ -96,6 +96,8 @@ function view(row) {
96
96
  createdAt: row.created_at,
97
97
  expiresAt: row.expires_at,
98
98
  promotion: row.promotion ?? null,
99
+ conversation: row.conversation,
100
+ senderId: row.owner_id,
99
101
  };
100
102
  }
101
103
  /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
@@ -110,6 +112,14 @@ export function attachmentRuntime(ontology, sql, provider) {
110
112
  return "denied";
111
113
  }
112
114
  yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
115
+ if (caller.access === "member") {
116
+ const admin = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
117
+ "agent_chat:update",
118
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
119
+ if (admin) {
120
+ return "administrator";
121
+ }
122
+ }
113
123
  const separator = caller.conversation.indexOf(":");
114
124
  const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
115
125
  WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
@@ -148,19 +158,21 @@ export function attachmentRuntime(ontology, sql, provider) {
148
158
  caller.principal.id,
149
159
  caller.conversation,
150
160
  binding.branchId,
151
- access === "group" && scope === "session",
161
+ (access === "group" || access === "administrator") && scope === "session",
152
162
  ]);
153
163
  const row = rows[0];
154
164
  if (!row) {
155
165
  return yield* missing(id);
156
166
  }
157
167
  if (access === "private" &&
168
+ caller.access !== "member" &&
158
169
  scope === "session" &&
159
170
  row.session_id !== null &&
160
171
  row.session_id !== caller.sessionId) {
161
172
  return yield* missing(id);
162
173
  }
163
174
  if (access === "private" &&
175
+ caller.access !== "member" &&
164
176
  scope === "session" &&
165
177
  row.session_id === null &&
166
178
  caller.sessionId !== undefined) {
@@ -401,6 +413,9 @@ export function attachmentRuntime(ontology, sql, provider) {
401
413
  const prepared = yield* sql.withTransaction(Effect.gen(function* () {
402
414
  const row = yield* load(id, caller);
403
415
  yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
416
+ if (caller.access === "member" && row.expired) {
417
+ return yield* unavailable("The attachment expired or was purged.");
418
+ }
404
419
  if (row.state === "promoted") {
405
420
  return { kind: "done", row };
406
421
  }
@@ -419,7 +434,7 @@ export function attachmentRuntime(ontology, sql, provider) {
419
434
  WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
420
435
  AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
421
436
  AND conversation = $5 AND (owner_id = $6::uuid OR $7)
422
- AND ($7 OR session_id IS NULL OR session_id = $8)
437
+ AND ($7 OR $9 OR session_id IS NULL OR session_id = $8)
423
438
  ORDER BY created_at LIMIT 1`, [
424
439
  row.sha256,
425
440
  binding.branchId,
@@ -427,8 +442,9 @@ export function attachmentRuntime(ontology, sql, provider) {
427
442
  qualified,
428
443
  caller.conversation,
429
444
  caller.principal.id,
430
- (yield* accessScope(caller)) === "group",
445
+ ["group", "administrator"].includes(yield* accessScope(caller)),
431
446
  caller.sessionId ?? null,
447
+ caller.access === "member",
432
448
  ]);
433
449
  if (earlier) {
434
450
  const promotion = {
@@ -528,7 +544,106 @@ export function attachmentRuntime(ontology, sql, provider) {
528
544
  }));
529
545
  return { attachment: view(promoted), deduplicated: false, result: outcome.result ?? null };
530
546
  });
531
- return { receive, complete, list, read, discard, promote };
547
+ /** Resolve the chat from storage, then authorize with the same locked loader. */
548
+ const memberCaller = (id, principal) => sql.withTransaction(Effect.gen(function* () {
549
+ if (!/^[0-9a-f-]{36}$/i.test(id)) {
550
+ return yield* missing(id);
551
+ }
552
+ const [row] = yield* sql.unsafe(`SELECT conversation FROM "${ATTACHMENTS_TABLE}" WHERE id = $1::uuid AND branch_id = $2`, [id, binding.branchId]);
553
+ if (!row) {
554
+ return yield* missing(id);
555
+ }
556
+ const caller = {
557
+ principal,
558
+ conversation: row.conversation,
559
+ access: "member",
560
+ };
561
+ yield* load(id, caller);
562
+ return caller;
563
+ }));
564
+ const getForMember = (id, principal) => Effect.gen(function* () {
565
+ const caller = yield* memberCaller(id, principal);
566
+ const row = yield* sql.withTransaction(load(id, caller));
567
+ if (!READABLE.has(row.state) || row.expired) {
568
+ return yield* unavailable("The attachment expired, was purged, or is not ready.");
569
+ }
570
+ return view(row);
571
+ });
572
+ const readForMember = (id, principal) => Effect.gen(function* () {
573
+ return yield* read(id, yield* memberCaller(id, principal));
574
+ });
575
+ const promoteForMember = (id, target, principal, run) => Effect.gen(function* () {
576
+ return yield* promote(id, target, yield* memberCaller(id, principal), run);
577
+ });
578
+ const listForMember = (filters, principal) => sql.withTransaction(Effect.gen(function* () {
579
+ const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [principal.id]);
580
+ if (member?.kind !== "member" || !member.active) {
581
+ return yield* missing("chat");
582
+ }
583
+ yield* authorizeBlobOperation(ontology, sql, principal.id, []);
584
+ const admin = yield* authorizeBlobOperation(ontology, sql, principal.id, [
585
+ "agent_chat:update",
586
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
587
+ if (filters.conversation !== undefined) {
588
+ const scope = yield* accessScope({
589
+ principal,
590
+ conversation: filters.conversation,
591
+ access: "member",
592
+ });
593
+ if (scope === "denied") {
594
+ return yield* missing("chat");
595
+ }
596
+ if (scope === "private") {
597
+ const [sent] = yield* sql.unsafe(`SELECT 1 FROM "${ATTACHMENTS_TABLE}" WHERE branch_id = $1 AND conversation = $2
598
+ AND owner_id = $3::uuid LIMIT 1`, [binding.branchId, filters.conversation, principal.id]);
599
+ if (sent === undefined) {
600
+ return yield* missing("chat");
601
+ }
602
+ }
603
+ }
604
+ const limit = filters.limit ?? 50;
605
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.branch_id = $1
606
+ AND ($2 OR EXISTS (
607
+ SELECT 1 FROM "agent_chat" c JOIN "agent_chat_members" m ON m.agent_chat_id = c.id
608
+ WHERE c.provider || ':' || c.external_id = a.conversation AND m.user_id = $3::uuid
609
+ ) OR (a.owner_id = $3::uuid AND NOT EXISTS (
610
+ SELECT 1 FROM "agent_chat" c WHERE c.provider || ':' || c.external_id = a.conversation
611
+ )))
612
+ AND ($4::text IS NULL OR a.conversation = $4)
613
+ AND ($5::uuid IS NULL OR a.owner_id = $5::uuid)
614
+ AND ($6::timestamptz IS NULL OR a.created_at >= $6::timestamptz)
615
+ AND ($7::timestamptz IS NULL OR a.created_at <= $7::timestamptz)
616
+ AND ($8::uuid IS NULL OR (a.created_at, a.id) < (
617
+ SELECT created_at, id FROM "${ATTACHMENTS_TABLE}" WHERE id = $8::uuid AND branch_id = $1
618
+ ))
619
+ ORDER BY a.created_at DESC, a.id DESC LIMIT $9`, [
620
+ binding.branchId,
621
+ admin,
622
+ principal.id,
623
+ filters.conversation ?? null,
624
+ filters.senderId ?? null,
625
+ filters.from ?? null,
626
+ filters.to ?? null,
627
+ filters.before ?? null,
628
+ limit + 1,
629
+ ]);
630
+ return {
631
+ attachments: rows.slice(0, limit).map(view),
632
+ nextCursor: rows.length > limit ? rows[limit - 1].id : null,
633
+ };
634
+ }));
635
+ return {
636
+ receive,
637
+ complete,
638
+ list,
639
+ read,
640
+ discard,
641
+ promote,
642
+ listForMember,
643
+ getForMember,
644
+ readForMember,
645
+ promoteForMember,
646
+ };
532
647
  }
533
648
  /**
534
649
  * Expires attachments past their deadline and removes the bytes of every expired or discarded one
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListMultipartUploadsCommand, ListPartsCommand, S3Client, UploadPartCommand, } from "@aws-sdk/client-s3";
2
3
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3
4
  import { Effect } from "effect";
@@ -168,6 +169,53 @@ export function neonBlobProvider(binding) {
168
169
  throw error;
169
170
  }
170
171
  }),
172
+ sha256: (key, size) => attempt("blob.sha256", async () => {
173
+ const response = await client.send(new GetObjectCommand(object(key)));
174
+ if (!response.Body) {
175
+ throw new Error("Neon returned no object body.");
176
+ }
177
+ const hash = createHash("sha256");
178
+ const reader = response.Body.transformToWebStream().getReader();
179
+ let received = 0;
180
+ let failure;
181
+ try {
182
+ for (;;) {
183
+ const { done, value } = await reader.read();
184
+ if (done) {
185
+ break;
186
+ }
187
+ received += value.byteLength;
188
+ if (received > size) {
189
+ throw new Error("The stored object exceeds its declared size.");
190
+ }
191
+ hash.update(value);
192
+ }
193
+ if (received !== size) {
194
+ throw new Error("The stored object does not match its declared size.");
195
+ }
196
+ return hash.digest("hex");
197
+ }
198
+ catch (cause) {
199
+ failure = cause;
200
+ throw cause;
201
+ }
202
+ finally {
203
+ try {
204
+ await reader.cancel();
205
+ }
206
+ catch (cleanup) {
207
+ if (failure !== undefined) {
208
+ throw new AggregateError([failure, cleanup], "Hashing and stream cleanup failed.", {
209
+ cause: cleanup,
210
+ });
211
+ }
212
+ throw cleanup;
213
+ }
214
+ finally {
215
+ reader.releaseLock();
216
+ }
217
+ }
218
+ }),
171
219
  readPrefix: (key) => attempt("blob.read_prefix", async () => {
172
220
  const response = await client.send(new GetObjectCommand({ ...object(key), Range: "bytes=0-511" }));
173
221
  if (!response.Body) {
@@ -60,6 +60,8 @@ declare const BlobProvider_base: Context.ServiceClass<BlobProvider, "@segmentstr
60
60
  }>;
61
61
  }) => Effect.Effect<void, BlobStorageError>;
62
62
  readonly inspect: (key: string) => Effect.Effect<StoredObject | undefined, BlobStorageError>;
63
+ /** Hash immutable content without buffering it. */
64
+ readonly sha256: (key: string, size: number) => Effect.Effect<string, BlobStorageError>;
63
65
  readonly readPrefix: (key: string) => Effect.Effect<Uint8Array, BlobStorageError>;
64
66
  /** The whole object, at most `maxBytes`; for verifying an agent attachment's digest. */
65
67
  readonly read: (key: string, maxBytes: number) => Effect.Effect<Uint8Array, BlobStorageError>;