@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.
- package/README.md +78 -0
- package/dist/entity.d.ts +53 -2
- package/dist/entity.js +31 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/runtime/batch-diagnostics.d.ts +22 -0
- package/dist/runtime/batch-diagnostics.js +89 -0
- package/dist/runtime/batch.d.ts +48 -0
- package/dist/runtime/batch.js +473 -0
- package/dist/runtime/records.d.ts +2 -2
- package/dist/runtime/records.js +25 -6
- package/dist/runtime/sql-runtime.js +125 -5
- package/dist/runtime.d.ts +51 -2
- package/dist/runtime.js +2 -1
- package/dist/service/errors.js +17 -0
- package/dist/service/mcp/server.js +11 -3
- package/dist/service/mcp/tools.d.ts +8 -1
- package/dist/service/mcp/tools.js +35 -1
- package/dist/service/member-file-routes.d.ts +22 -0
- package/dist/service/member-file-routes.js +52 -0
- package/dist/service/member-files.d.ts +165 -0
- package/dist/service/member-files.js +145 -0
- package/dist/service/schemas.js +4 -0
- package/dist/service/service.js +10 -1
- package/dist/storage/attachments.d.ts +26 -0
- package/dist/storage/attachments.js +119 -4
- package/dist/storage/neon.js +48 -0
- package/dist/storage/provider.d.ts +2 -0
- package/dist/storage/sessions.d.ts +20 -1
- package/dist/storage/sessions.js +36 -6
- package/package.json +4 -2
|
@@ -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
|
+
}
|
package/dist/service/schemas.js
CHANGED
|
@@ -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"]),
|
package/dist/service/service.js
CHANGED
|
@@ -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,
|
|
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))
|
|
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
|
-
|
|
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
|
package/dist/storage/neon.js
CHANGED
|
@@ -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>;
|