@segmentstream-cloud/ontology 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrate/apply.js +34 -1
- package/dist/replication/baseline.d.ts +18 -4
- package/dist/replication/baseline.js +153 -62
- package/dist/replication/legacy-baseline.d.ts +27 -0
- package/dist/replication/legacy-baseline.js +106 -0
- package/dist/replication/legacy-store.d.ts +23 -0
- package/dist/replication/legacy-store.js +61 -0
- package/dist/replication/schema.d.ts +1 -1
- package/dist/replication/schema.js +51 -5
- package/dist/replication/store.d.ts +14 -11
- package/dist/replication/store.js +251 -62
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +23 -4
- package/dist/runtime/action-transactions.d.ts +35 -0
- package/dist/runtime/action-transactions.js +54 -0
- package/dist/runtime/pipeline.js +17 -3
- package/dist/runtime/service-binding.js +4 -1
- package/dist/runtime/sql-runtime.d.ts +2 -0
- package/dist/runtime/sql-runtime.js +4 -2
- package/dist/service/agent/attachments.d.ts +3 -2
- package/dist/service/agent/attachments.js +4 -2
- package/dist/service/agent/authentication.d.ts +3 -1
- package/dist/service/agent/authentication.js +10 -4
- package/dist/service/replication.d.ts +1 -1
- package/dist/service/replication.js +27 -16
- package/dist/storage/attachments.d.ts +8 -7
- package/dist/storage/attachments.js +83 -23
- package/dist/storage/tables.d.ts +2 -1
- package/dist/storage/tables.js +2 -1
- package/package.json +1 -1
package/dist/runtime/pipeline.js
CHANGED
|
@@ -16,6 +16,7 @@ import { Edit } from "./history.js";
|
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
17
|
import { AgentFileRecords, resolveFileRecord } from "./files.js";
|
|
18
18
|
import { validate } from "./validation.js";
|
|
19
|
+
import { ACTION_TRANSACTION_LIMITS, ActionTransactionDeadline, ActionTransactions, } from "./action-transactions.js";
|
|
19
20
|
/** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
|
|
20
21
|
export const PREPARE_TIMEOUT = Duration.seconds(60);
|
|
21
22
|
export const ActionValidation = Schema.Struct({
|
|
@@ -173,6 +174,7 @@ export function runAction(ontology, request) {
|
|
|
173
174
|
...(request.branchId ? { branchId: request.branchId } : {}),
|
|
174
175
|
...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
|
|
175
176
|
onEdit: (edit) => edits.push(edit),
|
|
177
|
+
replicationInitialized: true,
|
|
176
178
|
});
|
|
177
179
|
const handle = action.handler;
|
|
178
180
|
const handler = Effect.suspend(() => handle({ ...context, prepared }));
|
|
@@ -209,9 +211,21 @@ export function runAction(ontology, request) {
|
|
|
209
211
|
}
|
|
210
212
|
return result;
|
|
211
213
|
});
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
const admission = Option.getOrUndefined(yield* Effect.serviceOption(ActionTransactions));
|
|
215
|
+
const limits = admission?.limits ?? ACTION_TRANSACTION_LIMITS;
|
|
216
|
+
const transactionBody = Effect.gen(function* () {
|
|
217
|
+
// Server deadlines also protect the reserved connection if client cancellation cannot arrive.
|
|
218
|
+
yield* sql.unsafe(`SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $2, true)`, [`${limits.statementTimeoutMs}ms`, `${limits.lockTimeoutMs}ms`]);
|
|
219
|
+
yield* beginReplicationWrite(sql);
|
|
220
|
+
return yield* transactionalHandler;
|
|
221
|
+
}).pipe(Effect.timeoutOrElse({
|
|
222
|
+
duration: Duration.millis(limits.handlerTimeoutMs),
|
|
223
|
+
orElse: () => Effect.logWarning("ontology.action.transaction_deadline").pipe(Effect.annotateLogs({ deadlineMs: limits.handlerTimeoutMs }), Effect.andThen(Effect.die(new ActionTransactionDeadline(`${qualified} exceeded its transaction deadline.`)))),
|
|
224
|
+
}));
|
|
225
|
+
// Time out the interruptible body only. The SQL manager must finish rollback/commit and
|
|
226
|
+
// release its connection; a lost COMMIT response must never cause an automatic replay.
|
|
227
|
+
const transaction = sql.withTransaction(transactionBody);
|
|
228
|
+
const result = yield* (admission ? admission.admit(transaction) : transaction).pipe(Effect.annotateLogs({ action: qualified, actionId, requestId }), Effect.catchIf(isSqlError, (error) => Effect.die(error)));
|
|
215
229
|
if (replay) {
|
|
216
230
|
return replay;
|
|
217
231
|
}
|
|
@@ -14,6 +14,7 @@ import { runAction } from "./pipeline.js";
|
|
|
14
14
|
import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity, resolvePipelineWorker, } from "./principals.js";
|
|
15
15
|
import { postgresTls } from "./postgres-tls.js";
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
|
+
import { ActionTransactions, makeActionTransactions } from "./action-transactions.js";
|
|
17
18
|
/** Connections one service instance holds to the database's pooler. */
|
|
18
19
|
const MAX_CONNECTIONS = 5;
|
|
19
20
|
/**
|
|
@@ -28,6 +29,7 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
28
29
|
return Layer.effect(OntologyBackend, Effect.gen(function* () {
|
|
29
30
|
const sql = yield* SqlClient.SqlClient;
|
|
30
31
|
const runtime = makeOntologyRuntime(ontology, sql);
|
|
32
|
+
const actionTransactions = makeActionTransactions();
|
|
31
33
|
const withSql = (effect) => Effect.provideService(effect, SqlClient.SqlClient, sql);
|
|
32
34
|
const binding = blobStorage ? Redacted.value(blobStorage) : undefined;
|
|
33
35
|
const provider = binding
|
|
@@ -49,7 +51,7 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
49
51
|
afterCommit: input.afterCommit === undefined ? [] : [input.afterCommit],
|
|
50
52
|
...(input.requestId === undefined ? {} : { requestId: input.requestId }),
|
|
51
53
|
...(input.connections === undefined ? {} : { connections: input.connections }),
|
|
52
|
-
})).pipe(Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
|
|
54
|
+
})).pipe(Effect.provideService(ActionTransactions, actionTransactions), Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
|
|
53
55
|
get: (input) => runtime.get(input),
|
|
54
56
|
find: (input) => runtime.query(input),
|
|
55
57
|
history: (input) => runtime.history(input),
|
|
@@ -87,6 +89,7 @@ export function postgresLayer(databaseUrl) {
|
|
|
87
89
|
url: databaseUrl,
|
|
88
90
|
ssl: postgresTls(databaseUrl),
|
|
89
91
|
maxConnections: MAX_CONNECTIONS,
|
|
92
|
+
connectTimeout: "5 seconds",
|
|
90
93
|
applicationName: "segmentstream-ontology",
|
|
91
94
|
});
|
|
92
95
|
}
|
|
@@ -11,6 +11,8 @@ import type { Edit, HistoryEntry, HistoryScope } from "./history.js";
|
|
|
11
11
|
* `created_by`/`updated_by` to its principal.
|
|
12
12
|
*/
|
|
13
13
|
export interface WriteScope extends HistoryScope {
|
|
14
|
+
/** Set only when the enclosing action initialized replication in its outer transaction. */
|
|
15
|
+
readonly replicationInitialized?: boolean;
|
|
14
16
|
/** Receives the edits of each write that succeeded, in order. */
|
|
15
17
|
readonly onEdit?: (edit: Edit) => void;
|
|
16
18
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
2
|
import { beginReplicationWrite } from "../replication/transaction.js";
|
|
3
3
|
import { isSqlError } from "effect/unstable/sql/SqlError";
|
|
4
4
|
import { Conflict } from "../errors.js";
|
|
@@ -100,8 +100,10 @@ export function makeOntologyRuntime(ontology, sql, scope) {
|
|
|
100
100
|
}));
|
|
101
101
|
}
|
|
102
102
|
const edits = [];
|
|
103
|
+
const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
|
|
104
|
+
const initialized = scope.replicationInitialized && Option.isSome(enclosingTransaction);
|
|
103
105
|
const result = yield* sql
|
|
104
|
-
.withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
|
|
106
|
+
.withTransaction((initialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
|
|
105
107
|
.pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error)));
|
|
106
108
|
for (const edit of edits) {
|
|
107
109
|
scope.onEdit?.(edit);
|
|
@@ -13,9 +13,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
|
13
13
|
/**
|
|
14
14
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
15
15
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
16
|
-
* while they are active
|
|
16
|
+
* while they are active. The chat and the agent session come from the agent's runtime in
|
|
17
17
|
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
18
|
-
* tool arguments
|
|
18
|
+
* tool arguments. Storage checks current group participation or chat administration; private files
|
|
19
|
+
* remain confined to their sender and session.
|
|
19
20
|
*/
|
|
20
21
|
export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
|
|
21
22
|
export interface AttachmentRoutesOptions {
|
|
@@ -12,9 +12,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
|
12
12
|
/**
|
|
13
13
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
14
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
15
|
-
* while they are active
|
|
15
|
+
* while they are active. The chat and the agent session come from the agent's runtime in
|
|
16
16
|
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
17
|
-
* tool arguments
|
|
17
|
+
* tool arguments. Storage checks current group participation or chat administration; private files
|
|
18
|
+
* remain confined to their sender and session.
|
|
18
19
|
*/
|
|
19
20
|
export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
|
|
20
21
|
const NO_STORE = { "cache-control": "no-store" };
|
|
@@ -111,6 +112,7 @@ export function attachmentsLayer(options) {
|
|
|
111
112
|
request,
|
|
112
113
|
agents,
|
|
113
114
|
verify: verifyRuntime,
|
|
115
|
+
allowUnpairedMember: true,
|
|
114
116
|
});
|
|
115
117
|
return authenticated.ok
|
|
116
118
|
? yield* answer(request, authenticated.caller, attachments)
|
|
@@ -6,11 +6,13 @@ import type { AgentStore } from "../runtime-contract.js";
|
|
|
6
6
|
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
7
7
|
export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
8
8
|
export declare const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
9
|
-
/**
|
|
9
|
+
/** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
|
|
10
10
|
export declare function authenticateAgentCaller(input: {
|
|
11
11
|
readonly request: HttpServerRequest.HttpServerRequest;
|
|
12
12
|
readonly agents: AgentStore;
|
|
13
13
|
readonly verify: RuntimeTokenVerifier;
|
|
14
|
+
/** Temporary attachments authorize active members against the chat registry, without a pairing. */
|
|
15
|
+
readonly allowUnpairedMember?: boolean;
|
|
14
16
|
}): Effect.Effect<{
|
|
15
17
|
readonly ok: false;
|
|
16
18
|
readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Effect, Redacted } from "effect";
|
|
2
2
|
import { HttpServerRequest } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
-
import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
|
|
4
|
+
import { RequestId, forbidden, notFound, toServerResponse, unauthorized, unavailable, } from "../errors.js";
|
|
5
5
|
import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
|
|
6
6
|
export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
7
7
|
export const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
8
8
|
const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
|
|
9
9
|
const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
10
|
-
/**
|
|
10
|
+
/** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
|
|
11
11
|
export function authenticateAgentCaller(input) {
|
|
12
12
|
return Effect.gen(function* () {
|
|
13
13
|
const { request, agents, verify } = input;
|
|
@@ -31,9 +31,15 @@ export function authenticateAgentCaller(input) {
|
|
|
31
31
|
const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
|
|
32
32
|
const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
|
|
33
33
|
const session = request.headers[SESSION_HEADER]?.trim() ?? "";
|
|
34
|
-
const member = actAs === ""
|
|
34
|
+
const member = actAs === ""
|
|
35
|
+
? undefined
|
|
36
|
+
: input.allowUnpairedMember
|
|
37
|
+
? (yield* agents.member(actAs))?.principal
|
|
38
|
+
: yield* agents.actingMember(actAs);
|
|
35
39
|
if (member === undefined) {
|
|
36
|
-
return denied(
|
|
40
|
+
return denied(input.allowUnpairedMember
|
|
41
|
+
? notFound(requestId, "Attachment not found.")
|
|
42
|
+
: forbidden(requestId, "The member is inactive or has not paired with the agent."));
|
|
37
43
|
}
|
|
38
44
|
if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
|
|
39
45
|
return denied(forbidden(requestId, "The agent names the chat and session of every request."));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { OntologyEnvironment } from "./config.js";
|
|
3
3
|
export type ReplicationKick = (identity: string | undefined) => Effect.Effect<void>;
|
|
4
|
-
/**
|
|
4
|
+
/** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
|
|
5
5
|
export declare function deployedReplication(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ReplicationKick;
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
-
/**
|
|
2
|
+
/** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
|
|
3
3
|
export function deployedReplication(environment, fetchImpl = fetch) {
|
|
4
|
+
let inFlight = false;
|
|
5
|
+
let nextWakeAt = 0;
|
|
4
6
|
return (identity) => {
|
|
5
7
|
if (!identity) {
|
|
6
8
|
return Effect.void;
|
|
7
9
|
}
|
|
8
|
-
return Effect.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
method: "POST",
|
|
12
|
-
redirect: "error",
|
|
13
|
-
signal: AbortSignal.timeout(5000),
|
|
14
|
-
headers: {
|
|
15
|
-
"x-segmentstream-runtime-identity": identity,
|
|
16
|
-
"x-segmentstream-organization-id": environment.organizationId,
|
|
17
|
-
},
|
|
18
|
-
});
|
|
19
|
-
await response.body?.cancel();
|
|
20
|
-
if (!response.ok) {
|
|
21
|
-
throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
|
|
10
|
+
return Effect.suspend(() => {
|
|
11
|
+
if (inFlight || Date.now() < nextWakeAt) {
|
|
12
|
+
return Effect.void;
|
|
22
13
|
}
|
|
23
|
-
|
|
14
|
+
inFlight = true;
|
|
15
|
+
nextWakeAt = Date.now() + 1000;
|
|
16
|
+
return Effect.tryPromise(async () => {
|
|
17
|
+
const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
|
|
18
|
+
`${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
redirect: "error",
|
|
21
|
+
signal: AbortSignal.timeout(5000),
|
|
22
|
+
headers: {
|
|
23
|
+
"x-segmentstream-runtime-identity": identity,
|
|
24
|
+
"x-segmentstream-organization-id": environment.organizationId,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
|
|
30
|
+
}
|
|
31
|
+
}).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)), Effect.ensuring(Effect.sync(() => {
|
|
32
|
+
inFlight = false;
|
|
33
|
+
})), Effect.forkDetach, Effect.asVoid);
|
|
34
|
+
});
|
|
24
35
|
};
|
|
25
36
|
}
|
|
@@ -6,11 +6,12 @@ import type { Principal } from "../runtime.js";
|
|
|
6
6
|
import type { BlobProvider, TransferGrant } from "./provider.js";
|
|
7
7
|
/**
|
|
8
8
|
* The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
|
|
9
|
-
* `_blobs` revision in the ontology's private file storage
|
|
10
|
-
*
|
|
11
|
-
* expires 24 hours after receipt;
|
|
12
|
-
* promote it into a record through an action, which adopts the same stored object
|
|
13
|
-
* it. Every operation
|
|
9
|
+
* `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
|
|
10
|
+
* members; private files belong to their sender and the agent session that first reads them.
|
|
11
|
+
* Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
|
|
12
|
+
* discard it, or promote it into a record through an action, which adopts the same stored object
|
|
13
|
+
* without copying it. Every operation derives chat access from the caller and registry, never from
|
|
14
|
+
* tool input.
|
|
14
15
|
*/
|
|
15
16
|
/** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
|
|
16
17
|
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
@@ -25,8 +26,8 @@ export interface AttachmentCaller {
|
|
|
25
26
|
/** The chat the attachment arrived in, such as `telegram:12345`. */
|
|
26
27
|
readonly conversation: string;
|
|
27
28
|
/**
|
|
28
|
-
* The agent session the request comes from.
|
|
29
|
-
*
|
|
29
|
+
* The agent session the request comes from. Private attachments stay bound to the first session
|
|
30
|
+
* that reads them. Group attachments are shared across authorized sessions in the same chat.
|
|
30
31
|
*/
|
|
31
32
|
readonly sessionId?: string;
|
|
32
33
|
}
|
|
@@ -9,11 +9,12 @@ import { authorizeBlobOperation } from "./authorization.js";
|
|
|
9
9
|
import { ATTACHMENTS_TABLE } from "./tables.js";
|
|
10
10
|
/**
|
|
11
11
|
* The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
|
|
12
|
-
* `_blobs` revision in the ontology's private file storage
|
|
13
|
-
*
|
|
14
|
-
* expires 24 hours after receipt;
|
|
15
|
-
* promote it into a record through an action, which adopts the same stored object
|
|
16
|
-
* it. Every operation
|
|
12
|
+
* `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
|
|
13
|
+
* members; private files belong to their sender and the agent session that first reads them.
|
|
14
|
+
* Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
|
|
15
|
+
* discard it, or promote it into a record through an action, which adopts the same stored object
|
|
16
|
+
* without copying it. Every operation derives chat access from the caller and registry, never from
|
|
17
|
+
* tool input.
|
|
17
18
|
*/
|
|
18
19
|
/** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
|
|
19
20
|
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
@@ -102,9 +103,34 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
102
103
|
const binding = provider.binding;
|
|
103
104
|
const missing = (id) => new NotFound({ entity: "attachment", id, message: "Attachment not found." });
|
|
104
105
|
const unavailable = (message) => new Conflict({ entity: "attachment", message });
|
|
106
|
+
/** The registry contains groups only. Neither a tool argument nor a caller's roles name a group. */
|
|
107
|
+
const accessScope = (caller) => Effect.gen(function* () {
|
|
108
|
+
const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [caller.principal.id]);
|
|
109
|
+
if (member?.kind !== "member" || !member.active) {
|
|
110
|
+
return "denied";
|
|
111
|
+
}
|
|
112
|
+
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
|
|
113
|
+
const separator = caller.conversation.indexOf(":");
|
|
114
|
+
const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
|
|
115
|
+
WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
|
|
116
|
+
if (chat === undefined) {
|
|
117
|
+
return "private";
|
|
118
|
+
}
|
|
119
|
+
const [participant] = yield* sql.unsafe(`SELECT 1 FROM "agent_chat_members"
|
|
120
|
+
WHERE agent_chat_id = $1::uuid AND user_id = $2::uuid FOR SHARE`, [chat.id, caller.principal.id]);
|
|
121
|
+
if (participant !== undefined) {
|
|
122
|
+
return "group";
|
|
123
|
+
}
|
|
124
|
+
// App roles are authored by the workspace. Chat management grants its administrator access;
|
|
125
|
+
// re-read the roles here rather than trusting permissions cached on the principal.
|
|
126
|
+
const administrator = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
|
|
127
|
+
"agent_chat:update",
|
|
128
|
+
]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
|
|
129
|
+
return administrator ? "group" : "denied";
|
|
130
|
+
});
|
|
105
131
|
/**
|
|
106
|
-
* The caller's attachment, locked.
|
|
107
|
-
*
|
|
132
|
+
* The caller's attachment, locked. Group access follows current chat membership. Private access
|
|
133
|
+
* requires the sender and their session. Source transfers always require the original sender.
|
|
108
134
|
*/
|
|
109
135
|
const load = (id, caller,
|
|
110
136
|
/** `source`: the webhook redelivering the file it stored, before or after a session read it. */
|
|
@@ -112,23 +138,37 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
112
138
|
if (!/^[0-9a-f-]{36}$/i.test(id)) {
|
|
113
139
|
return yield* missing(id);
|
|
114
140
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
141
|
+
const access = yield* accessScope(caller);
|
|
142
|
+
if (access === "denied") {
|
|
143
|
+
return yield* missing(id);
|
|
144
|
+
}
|
|
145
|
+
const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND (a.owner_id = $2::uuid OR $5) AND a.conversation = $3
|
|
146
|
+
AND a.branch_id = $4 FOR UPDATE OF a, b`, [
|
|
147
|
+
id,
|
|
148
|
+
caller.principal.id,
|
|
149
|
+
caller.conversation,
|
|
150
|
+
binding.branchId,
|
|
151
|
+
access === "group" && scope === "session",
|
|
152
|
+
]);
|
|
117
153
|
const row = rows[0];
|
|
118
154
|
if (!row) {
|
|
119
155
|
return yield* missing(id);
|
|
120
156
|
}
|
|
121
|
-
if (
|
|
157
|
+
if (access === "private" &&
|
|
158
|
+
scope === "session" &&
|
|
159
|
+
row.session_id !== null &&
|
|
160
|
+
row.session_id !== caller.sessionId) {
|
|
122
161
|
return yield* missing(id);
|
|
123
162
|
}
|
|
124
|
-
if (
|
|
163
|
+
if (access === "private" &&
|
|
164
|
+
scope === "session" &&
|
|
165
|
+
row.session_id === null &&
|
|
166
|
+
caller.sessionId !== undefined) {
|
|
125
167
|
yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET session_id = $1 WHERE id = $2::uuid`, [
|
|
126
168
|
caller.sessionId,
|
|
127
169
|
row.id,
|
|
128
170
|
]);
|
|
129
171
|
}
|
|
130
|
-
// The member must still be active; a deactivated member's agent reads nothing.
|
|
131
|
-
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
|
|
132
172
|
return row;
|
|
133
173
|
});
|
|
134
174
|
/**
|
|
@@ -203,7 +243,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
203
243
|
input.size,
|
|
204
244
|
input.sha256,
|
|
205
245
|
]);
|
|
206
|
-
return yield* load(id, caller);
|
|
246
|
+
return yield* load(id, caller, "source");
|
|
207
247
|
}));
|
|
208
248
|
if (reserved.state !== "received" || reserved.expired) {
|
|
209
249
|
return { attachment: view(reserved) };
|
|
@@ -232,7 +272,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
232
272
|
});
|
|
233
273
|
/** Completes the transfer, then verifies the stored bytes against the declared size and digest. */
|
|
234
274
|
const complete = (id, caller) => Effect.gen(function* () {
|
|
235
|
-
const row = yield* sql.withTransaction(load(id, caller));
|
|
275
|
+
const row = yield* sql.withTransaction(load(id, caller, "source"));
|
|
236
276
|
if (row.state !== "received") {
|
|
237
277
|
return READABLE.has(row.state) && !row.expired
|
|
238
278
|
? view(row)
|
|
@@ -267,16 +307,25 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
267
307
|
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
268
308
|
yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'ready'
|
|
269
309
|
WHERE id = $1::uuid AND state = 'received'`, [row.id]);
|
|
270
|
-
return view(yield* load(row.id, caller));
|
|
310
|
+
return view(yield* load(row.id, caller, "source"));
|
|
271
311
|
}));
|
|
272
312
|
});
|
|
273
|
-
/**
|
|
313
|
+
/** Accessible attachments in this chat that are still readable, newest first. */
|
|
274
314
|
const list = (caller) => sql.withTransaction(Effect.gen(function* () {
|
|
275
|
-
yield*
|
|
276
|
-
|
|
315
|
+
const access = yield* accessScope(caller);
|
|
316
|
+
if (access === "denied") {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const rows = yield* sql.unsafe(`${SELECT} WHERE (a.owner_id = $1::uuid OR $5) AND a.conversation = $2 AND a.branch_id = $3
|
|
277
320
|
AND a.state IN ('ready', 'promoted') AND a.expires_at > now()
|
|
278
|
-
AND (a.session_id IS NULL OR a.session_id = $4)
|
|
279
|
-
ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
|
|
321
|
+
AND ($5 OR a.session_id IS NULL OR a.session_id = $4)
|
|
322
|
+
ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
|
|
323
|
+
caller.principal.id,
|
|
324
|
+
caller.conversation,
|
|
325
|
+
binding.branchId,
|
|
326
|
+
caller.sessionId ?? null,
|
|
327
|
+
access === "group",
|
|
328
|
+
]);
|
|
280
329
|
return rows.map(view);
|
|
281
330
|
}));
|
|
282
331
|
/**
|
|
@@ -351,6 +400,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
351
400
|
const qualified = `${target.entity}.${target.action}`;
|
|
352
401
|
const prepared = yield* sql.withTransaction(Effect.gen(function* () {
|
|
353
402
|
const row = yield* load(id, caller);
|
|
403
|
+
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
|
|
354
404
|
if (row.state === "promoted") {
|
|
355
405
|
return { kind: "done", row };
|
|
356
406
|
}
|
|
@@ -365,11 +415,21 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
365
415
|
`${row.content_type}, ${row.size} bytes.`,
|
|
366
416
|
});
|
|
367
417
|
}
|
|
368
|
-
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
|
|
369
418
|
const [earlier] = yield* sql.unsafe(`SELECT id::text, promotion FROM "${ATTACHMENTS_TABLE}"
|
|
370
419
|
WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
|
|
371
420
|
AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
|
|
372
|
-
|
|
421
|
+
AND conversation = $5 AND (owner_id = $6::uuid OR $7)
|
|
422
|
+
AND ($7 OR session_id IS NULL OR session_id = $8)
|
|
423
|
+
ORDER BY created_at LIMIT 1`, [
|
|
424
|
+
row.sha256,
|
|
425
|
+
binding.branchId,
|
|
426
|
+
row.id,
|
|
427
|
+
qualified,
|
|
428
|
+
caller.conversation,
|
|
429
|
+
caller.principal.id,
|
|
430
|
+
(yield* accessScope(caller)) === "group",
|
|
431
|
+
caller.sessionId ?? null,
|
|
432
|
+
]);
|
|
373
433
|
if (earlier) {
|
|
374
434
|
const promotion = {
|
|
375
435
|
...earlier.promotion,
|
package/dist/storage/tables.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const ATTACHMENTS_TABLE = "_agent_attachments";
|
|
|
5
5
|
/**
|
|
6
6
|
* A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
|
|
7
7
|
* a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
|
|
8
|
-
* and the agent session that
|
|
8
|
+
* and, for private chats, the first agent session that reads them. Group access follows the chat
|
|
9
|
+
* registry's current members. Attachments expire 24 hours after receipt unless promoted.
|
|
9
10
|
*/
|
|
10
11
|
export declare const ATTACHMENT_TABLES_DDL: ReadonlyArray<string>;
|
package/dist/storage/tables.js
CHANGED
|
@@ -58,7 +58,8 @@ export const ATTACHMENTS_TABLE = "_agent_attachments";
|
|
|
58
58
|
/**
|
|
59
59
|
* A file a member sent the app's agent: platform metadata, never an ontology record. The bytes are
|
|
60
60
|
* a `_blobs` revision in the same storage as uploads; the row binds them to their owner, the chat
|
|
61
|
-
* and the agent session that
|
|
61
|
+
* and, for private chats, the first agent session that reads them. Group access follows the chat
|
|
62
|
+
* registry's current members. Attachments expire 24 hours after receipt unless promoted.
|
|
62
63
|
*/
|
|
63
64
|
export const ATTACHMENT_TABLES_DDL = [
|
|
64
65
|
`CREATE TABLE IF NOT EXISTS "${ATTACHMENTS_TABLE}" (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|