@segmentstream-cloud/ontology 0.26.0 → 0.26.2
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/dist/action.d.ts +3 -1
- package/dist/service/mcp/server.js +16 -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/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 +1 -1
package/dist/action.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export interface ActionContext<Input, Prepared = undefined> extends PrepareConte
|
|
|
18
18
|
/** What `prepare` may fail with: the action errors, and connection calls that failed. */
|
|
19
19
|
export type PrepareError = ActionError | ConnectionError;
|
|
20
20
|
/**
|
|
21
|
-
* How an action behaves. Agents confirm destructive or
|
|
21
|
+
* How an action behaves. Agents confirm destructive, irreversible or external actions before
|
|
22
|
+
* preparation. Actions with connections are external even when both safety hints are false.
|
|
23
|
+
* Permitted, routine reversible actions can run without confirmation.
|
|
22
24
|
* Custom actions without safety labels require confirmation for backwards compatibility.
|
|
23
25
|
* Idempotence does not decide whether confirmation is needed.
|
|
24
26
|
*/
|
|
@@ -17,6 +17,7 @@ import { OntologyBackend } from "../runtime-contract.js";
|
|
|
17
17
|
import { runtimeCredential } from "../runtime-identity.js";
|
|
18
18
|
import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
|
|
19
19
|
import { uploadFields } from "../../storage/attachments.js";
|
|
20
|
+
import { memberFileOperations } from "../member-files.js";
|
|
20
21
|
import { ACTION_OPTIONS_ARGUMENT, mcpTools, visibleTools } from "./tools.js";
|
|
21
22
|
/** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
|
|
22
23
|
export const MCP_PATH = "/mcp";
|
|
@@ -169,7 +170,7 @@ function toolFailure(cause, requestId) {
|
|
|
169
170
|
isError: true,
|
|
170
171
|
}));
|
|
171
172
|
}
|
|
172
|
-
function runTool({ built, operations, aggregate, conversations }, tool, args, request) {
|
|
173
|
+
function runTool({ built, operations, aggregate, conversations, memberFiles }, tool, args, request) {
|
|
173
174
|
return Effect.gen(function* () {
|
|
174
175
|
const { target } = tool;
|
|
175
176
|
switch (target.kind) {
|
|
@@ -197,6 +198,8 @@ function runTool({ built, operations, aggregate, conversations }, tool, args, re
|
|
|
197
198
|
const { id } = yield* decodeArguments(GetArguments, args);
|
|
198
199
|
return structured(yield* encoded(schemas.record, yield* operations.get(target.planned, id)));
|
|
199
200
|
}
|
|
201
|
+
case "member-file":
|
|
202
|
+
return structured(yield* memberFiles(target.operation, args, identityToken(request)));
|
|
200
203
|
case "conversation-search": {
|
|
201
204
|
const search = yield* decodeArguments(ConversationSearch, args);
|
|
202
205
|
const principal = yield* CurrentPrincipal;
|
|
@@ -333,6 +336,11 @@ function instructions({ built }, permissions) {
|
|
|
333
336
|
: []),
|
|
334
337
|
"Call describe for the full catalog: properties, relationships, measures and the actions " +
|
|
335
338
|
"you may run.",
|
|
339
|
+
"Use the authenticated tool annotations for confirmation: permitted actions classified as " +
|
|
340
|
+
"non-destructive and reversible need no extra approval. Destructive, irreversible or " +
|
|
341
|
+
"external actions (openWorldHint) require confirmation before preparation; idempotence " +
|
|
342
|
+
"alone does not waive it. Present confirmations in the conversation's language using " +
|
|
343
|
+
"action, entity, field and option labels and readable record titles, never tool names, ids or JSON.",
|
|
336
344
|
].join("\n\n");
|
|
337
345
|
}
|
|
338
346
|
function negotiate(requested) {
|
|
@@ -463,14 +471,19 @@ function handlePost(server, verifyToken, verifyRuntime, backend) {
|
|
|
463
471
|
* authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
|
|
464
472
|
*/
|
|
465
473
|
export function mcpLayer(options) {
|
|
466
|
-
const tools = mcpTools(options.built, options.conversations !== undefined);
|
|
467
474
|
return Layer.effectDiscard(Effect.gen(function* () {
|
|
468
475
|
const router = yield* HttpRouter.HttpRouter;
|
|
469
476
|
const backend = yield* OntologyBackend;
|
|
477
|
+
const tools = mcpTools(options.built, options.conversations !== undefined, {
|
|
478
|
+
attachments: backend.attachments !== undefined,
|
|
479
|
+
files: backend.blobs !== undefined,
|
|
480
|
+
});
|
|
481
|
+
const operations = makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
|
|
470
482
|
const server = {
|
|
471
483
|
built: options.built,
|
|
472
484
|
tools,
|
|
473
|
-
operations
|
|
485
|
+
operations,
|
|
486
|
+
memberFiles: memberFileOperations(backend, options.built.plan, operations),
|
|
474
487
|
aggregate: options.aggregates.model,
|
|
475
488
|
conversations: options.conversations,
|
|
476
489
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JsonSchemaObject } from "../../analyze.js";
|
|
2
2
|
import { type PermissionHolder } from "../../permissions.js";
|
|
3
|
+
import { type MemberFileOperation } from "../member-files.js";
|
|
3
4
|
import type { BuiltApi } from "../api.js";
|
|
4
5
|
import type { PlannedAction, PlannedEntity } from "../plan.js";
|
|
5
6
|
/** How a tool behaves, as MCP's tool annotations say it. */
|
|
@@ -28,6 +29,9 @@ export type McpToolTarget = {
|
|
|
28
29
|
readonly kind: "aggregate";
|
|
29
30
|
} | {
|
|
30
31
|
readonly kind: "conversation-search";
|
|
32
|
+
} | {
|
|
33
|
+
readonly kind: "member-file";
|
|
34
|
+
readonly operation: MemberFileOperation;
|
|
31
35
|
};
|
|
32
36
|
/** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
|
|
33
37
|
export interface McpTool {
|
|
@@ -60,7 +64,10 @@ export declare const ACTION_OPTIONS_ARGUMENT = "_options";
|
|
|
60
64
|
* as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
|
|
61
65
|
* `check` already refuses.
|
|
62
66
|
*/
|
|
63
|
-
export declare function mcpTools(built: BuiltApi, conversations?: boolean
|
|
67
|
+
export declare function mcpTools(built: BuiltApi, conversations?: boolean, storage?: {
|
|
68
|
+
attachments?: boolean;
|
|
69
|
+
files?: boolean;
|
|
70
|
+
}): ReadonlyArray<McpTool>;
|
|
64
71
|
/**
|
|
65
72
|
* The tools a caller may see: those whose every permission the caller's roles grant, each with the
|
|
66
73
|
* caller's input schema.
|
|
@@ -8,6 +8,7 @@ import { queryJsonSchema } from "../../query.js";
|
|
|
8
8
|
import { measureDescription, measuresFor, modelMeasureDescription, modelMeasuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
|
|
9
9
|
import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
|
|
10
10
|
import { uploadFields } from "../../storage/attachments.js";
|
|
11
|
+
import { MEMBER_FILE_SCHEMAS } from "../member-files.js";
|
|
11
12
|
import { actionDescription, requires } from "../descriptions.js";
|
|
12
13
|
import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, HistoryPage, MAXIMUM_TIMEZONE_LENGTH, Me, } from "../schemas.js";
|
|
13
14
|
const DEFS = "#/$defs/";
|
|
@@ -424,7 +425,7 @@ function aggregateTool(manifest) {
|
|
|
424
425
|
* as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
|
|
425
426
|
* `check` already refuses.
|
|
426
427
|
*/
|
|
427
|
-
export function mcpTools(built, conversations = false) {
|
|
428
|
+
export function mcpTools(built, conversations = false, storage = {}) {
|
|
428
429
|
const { manifest } = built.plan.ontology;
|
|
429
430
|
const tools = [
|
|
430
431
|
...GLOBAL_TOOLS,
|
|
@@ -482,6 +483,39 @@ export function mcpTools(built, conversations = false) {
|
|
|
482
483
|
target: { kind: "conversation-search" },
|
|
483
484
|
});
|
|
484
485
|
}
|
|
486
|
+
const descriptions = {
|
|
487
|
+
"attachments-list": "List chat attachments by conversation (provider:chat ID), sender member UUID and ISO time range. " +
|
|
488
|
+
"Returns a bounded page and nextCursor; use before to resume. Includes expired metadata. " +
|
|
489
|
+
"Administrators with agent_chat:update may access any workspace chat; active group participants " +
|
|
490
|
+
"and DM senders may access their files.",
|
|
491
|
+
"attachments-get": "Read metadata of an accessible chat attachment. Expired or purged attachments return a clear error.",
|
|
492
|
+
"attachments-download": "Download an accessible chat attachment. Returns filename, content type, size, SHA-256 and a " +
|
|
493
|
+
"short-lived GET grant. Stream the URL's bytes with its supplied headers before expiry; do not " +
|
|
494
|
+
"forward your app bearer token.",
|
|
495
|
+
"attachments-promote": "Keep a chat attachment through an ontology action such as document.upload. Supply the action " +
|
|
496
|
+
"input without the file; the platform adopts the stored revision, fills SHA-256 when declared, " +
|
|
497
|
+
"and enforces your current action permissions. Retries reuse the promotion result.",
|
|
498
|
+
"files-download": "Download a stored blob property of a record you may read. property defaults to content. Returns " +
|
|
499
|
+
"immutable revision metadata, filename, content type, size, SHA-256 and a short-lived GET grant. " +
|
|
500
|
+
"Stream bytes from the URL with its supplied headers before expiry; do not forward your app " +
|
|
501
|
+
"bearer token.",
|
|
502
|
+
};
|
|
503
|
+
for (const operation of Object.keys(MEMBER_FILE_SCHEMAS)) {
|
|
504
|
+
if (operation === "files-download" ? !storage.files : !storage.attachments) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
tools.push({
|
|
508
|
+
name: operation.replaceAll("-", "_"),
|
|
509
|
+
title: operation.replaceAll("-", " "),
|
|
510
|
+
description: descriptions[operation],
|
|
511
|
+
inputSchema: encodedJsonSchema(MEMBER_FILE_SCHEMAS[operation]),
|
|
512
|
+
annotations: operation === "attachments-promote"
|
|
513
|
+
? { readOnlyHint: false, idempotentHint: true, openWorldHint: true }
|
|
514
|
+
: READ,
|
|
515
|
+
permissions: [],
|
|
516
|
+
target: { kind: "member-file", operation },
|
|
517
|
+
});
|
|
518
|
+
}
|
|
485
519
|
const seen = new Set();
|
|
486
520
|
const duplicates = tools.filter(({ name }) => seen.size === seen.add(name).size);
|
|
487
521
|
if (duplicates.length > 0) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { type SessionExchange, type TokenVerifier } from "./authentication.js";
|
|
4
|
+
import type { AgentChannelFactory } from "./agent/channel.js";
|
|
5
|
+
import type { AfterCommitHook } from "./config.js";
|
|
6
|
+
import type { ConnectionTransportFactory } from "./connections.js";
|
|
7
|
+
import type { ApiPlan } from "./plan.js";
|
|
8
|
+
import type { ReplicationKick } from "./replication.js";
|
|
9
|
+
import { RequestId } from "./errors.js";
|
|
10
|
+
import { OntologyBackend } from "./runtime-contract.js";
|
|
11
|
+
interface MemberFileRoutesOptions {
|
|
12
|
+
readonly plan: ApiPlan;
|
|
13
|
+
readonly hooks: ReadonlyMap<string, AfterCommitHook>;
|
|
14
|
+
readonly connections: ConnectionTransportFactory;
|
|
15
|
+
readonly replicate: ReplicationKick;
|
|
16
|
+
readonly agentChannel?: AgentChannelFactory | undefined;
|
|
17
|
+
readonly verifyToken: TokenVerifier;
|
|
18
|
+
readonly exchangeSession?: SessionExchange | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Authenticated users use these routes; the agent's receive/discard routes stay runtime-owned. */
|
|
21
|
+
export declare function memberFileRoutes(options: MemberFileRoutesOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Cause, Effect, Layer } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
+
import { withPrincipal } from "./authentication.js";
|
|
5
|
+
import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
|
|
6
|
+
import { identityToken, makeOperations } from "./operations.js";
|
|
7
|
+
import { memberFileOperations } from "./member-files.js";
|
|
8
|
+
import { OntologyBackend } from "./runtime-contract.js";
|
|
9
|
+
/** Authenticated users use these routes; the agent's receive/discard routes stay runtime-owned. */
|
|
10
|
+
export function memberFileRoutes(options) {
|
|
11
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
12
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
13
|
+
const backend = yield* OntologyBackend;
|
|
14
|
+
const run = memberFileOperations(backend, options.plan, makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel));
|
|
15
|
+
const route = (kind) => (request) => Effect.gen(function* () {
|
|
16
|
+
const bearer = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request));
|
|
17
|
+
const operation = Effect.gen(function* () {
|
|
18
|
+
const params = yield* HttpRouter.params;
|
|
19
|
+
let args = params;
|
|
20
|
+
if (kind === "attachments-list") {
|
|
21
|
+
const query = Object.fromEntries(new URL(request.url, "https://app.invalid").searchParams);
|
|
22
|
+
args = {
|
|
23
|
+
...query,
|
|
24
|
+
...(query.limit === undefined ? {} : { limit: Number(query.limit) }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
else if (kind === "attachments-promote") {
|
|
28
|
+
const body = yield* request.json.pipe(Effect.mapError(() => new InvalidRequestBody({ reason: "invalid_json", issues: [] })));
|
|
29
|
+
args =
|
|
30
|
+
typeof body === "object" && body !== null && !Array.isArray(body)
|
|
31
|
+
? { ...body, id: params.id }
|
|
32
|
+
: body;
|
|
33
|
+
}
|
|
34
|
+
const answer = yield* run(kind, args, identityToken(request));
|
|
35
|
+
return HttpServerResponse.jsonUnsafe(answer, {
|
|
36
|
+
headers: { "cache-control": "private, no-store" },
|
|
37
|
+
});
|
|
38
|
+
}).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
|
|
39
|
+
? Effect.failCause(cause)
|
|
40
|
+
: Effect.flatMap(RequestId, (id) => failureResponse(cause, id))));
|
|
41
|
+
return yield* withPrincipal(bearer, options.verifyToken, backend, operation, {
|
|
42
|
+
session: { request, exchange: options.exchangeSession },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
yield* router.add("GET", "/api/ontology/attachments", route("attachments-list"));
|
|
46
|
+
yield* router.add("GET", "/api/ontology/attachments/:id", route("attachments-get"));
|
|
47
|
+
yield* router.add("GET", "/api/ontology/attachments/:id/download", route("attachments-download"));
|
|
48
|
+
yield* router.add("POST", "/api/ontology/attachments/:id/promote", route("attachments-promote"));
|
|
49
|
+
yield* router.add("GET", "/api/ontology/files/:entity/:id/:property", route("files-download"));
|
|
50
|
+
yield* router.add("GET", "/api/ontology/files/:entity/:id", route("files-download"));
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -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/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>;
|
|
@@ -82,6 +82,25 @@ export declare function blobRuntime(ontology: Ontology, sql: SqlClient.SqlClient
|
|
|
82
82
|
state: string;
|
|
83
83
|
expiresAt: string;
|
|
84
84
|
}, import("../errors.js").PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
85
|
-
download: (entityName: string, recordId: string, propertyName: string, input:
|
|
85
|
+
download: (entityName: string, recordId: string, propertyName: string, input: {
|
|
86
|
+
readonly inline?: boolean | undefined;
|
|
87
|
+
readonly revisionId?: string | undefined;
|
|
88
|
+
}, principal: Principal) => Effect.Effect<import("./provider.js").TransferGrant, import("../errors.js").PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
89
|
+
downloadFile: (entityName: string, recordId: string, propertyName: string, input: {
|
|
90
|
+
readonly inline?: boolean | undefined;
|
|
91
|
+
readonly revisionId?: string | undefined;
|
|
92
|
+
}, principal: Principal) => Effect.Effect<{
|
|
93
|
+
file: {
|
|
94
|
+
id: string;
|
|
95
|
+
entity: string;
|
|
96
|
+
property: string;
|
|
97
|
+
revision_id: string;
|
|
98
|
+
filename: string;
|
|
99
|
+
content_type: string;
|
|
100
|
+
size: number;
|
|
101
|
+
sha256: string;
|
|
102
|
+
};
|
|
103
|
+
download: import("./provider.js").TransferGrant;
|
|
104
|
+
}, import("../errors.js").PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
|
|
86
105
|
};
|
|
87
106
|
export type BlobRuntime = ReturnType<typeof blobRuntime>;
|
package/dist/storage/sessions.js
CHANGED
|
@@ -299,7 +299,7 @@ export function blobRuntime(ontology, sql, provider) {
|
|
|
299
299
|
WHERE id = $1::uuid`, [id]);
|
|
300
300
|
return view(yield* load(id, principal));
|
|
301
301
|
}));
|
|
302
|
-
const
|
|
302
|
+
const prepareDownload = (entityName, recordId, propertyName, input, principal) => Effect.gen(function* () {
|
|
303
303
|
const entity = ontology.manifest.entities.find((item) => item.name === entityName);
|
|
304
304
|
const property = entity?.properties.find((item) => item.name === propertyName && item.type === "blob");
|
|
305
305
|
if (!entity || !property) {
|
|
@@ -319,9 +319,10 @@ export function blobRuntime(ontology, sql, provider) {
|
|
|
319
319
|
});
|
|
320
320
|
revisionId = record[propertyName]?.id;
|
|
321
321
|
}
|
|
322
|
-
const [blob] = yield* sql.unsafe(`SELECT b.key, b.filename, b.content_type
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
const [blob] = yield* sql.unsafe(`SELECT b.id::text, b.key, b.filename, b.content_type, b.size::text
|
|
323
|
+
FROM "_blobs" b JOIN "_blob_references" r ON r.blob_id = b.id
|
|
324
|
+
WHERE b.id = $1::uuid AND b.state = 'retained' AND b.branch_id = $5 AND r.entity_id = $2
|
|
325
|
+
AND r.property_id = $3 AND r.record_id = $4::uuid FOR SHARE OF b`, [revisionId ?? null, entity.id, property.name, recordId, binding.branchId]);
|
|
325
326
|
if (!blob) {
|
|
326
327
|
return yield* new NotFound({
|
|
327
328
|
entity: entityName,
|
|
@@ -331,13 +332,42 @@ export function blobRuntime(ontology, sql, provider) {
|
|
|
331
332
|
}
|
|
332
333
|
return blob;
|
|
333
334
|
}));
|
|
335
|
+
return blob;
|
|
336
|
+
});
|
|
337
|
+
const download = (...args) => Effect.gen(function* () {
|
|
338
|
+
const blob = yield* prepareDownload(...args);
|
|
334
339
|
return yield* provider.download({
|
|
335
340
|
key: blob.key,
|
|
336
341
|
filename: blob.filename,
|
|
337
342
|
contentType: blob.content_type,
|
|
338
|
-
inline:
|
|
343
|
+
inline: args[3].inline === true &&
|
|
339
344
|
["image/jpeg", "image/png"].includes(blob.content_type.toLowerCase()),
|
|
340
345
|
});
|
|
341
346
|
});
|
|
342
|
-
|
|
347
|
+
const downloadFile = (...args) => Effect.gen(function* () {
|
|
348
|
+
const blob = yield* prepareDownload(...args);
|
|
349
|
+
const sha256 = yield* provider.sha256(blob.key, Number(blob.size));
|
|
350
|
+
// Recheck live access after hashing; no provider I/O holds a database transaction.
|
|
351
|
+
yield* prepareDownload(args[0], args[1], args[2], { revisionId: blob.id }, args[4]);
|
|
352
|
+
const grant = yield* provider.download({
|
|
353
|
+
key: blob.key,
|
|
354
|
+
filename: blob.filename,
|
|
355
|
+
contentType: blob.content_type,
|
|
356
|
+
inline: false,
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
file: {
|
|
360
|
+
id: args[1],
|
|
361
|
+
entity: args[0],
|
|
362
|
+
property: args[2],
|
|
363
|
+
revision_id: blob.id,
|
|
364
|
+
filename: blob.filename,
|
|
365
|
+
content_type: blob.content_type,
|
|
366
|
+
size: Number(blob.size),
|
|
367
|
+
sha256,
|
|
368
|
+
},
|
|
369
|
+
download: grant,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
return { begin, status, part, complete, cancel, download, downloadFile };
|
|
343
373
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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",
|