@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 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 irreversible actions before preparation.
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: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
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): ReadonlyArray<McpTool>;
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
+ }
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { memberFileRoutes } from "./member-file-routes.js";
7
8
  import { attachmentsLayer } from "./agent/attachments.js";
8
9
  import { filesLayer } from "./agent/files.js";
9
10
  import { platformConversations } from "./agent/conversations.js";
@@ -131,7 +132,15 @@ export function createOntologyService(ontology, config) {
131
132
  verifyToken,
132
133
  verifyRuntime,
133
134
  });
134
- const application = Layer.mergeAll(routes, mcp, agent, attachments, filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
135
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, memberFileRoutes({
136
+ plan: built.plan,
137
+ hooks,
138
+ connections,
139
+ replicate,
140
+ agentChannel,
141
+ verifyToken,
142
+ exchangeSession,
143
+ }), filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
135
144
  ? Layer.empty
136
145
  : conversationRoutes({
137
146
  transport: conversations,
@@ -30,6 +30,8 @@ export interface AttachmentCaller {
30
30
  * that reads them. Group attachments are shared across authorized sessions in the same chat.
31
31
  */
32
32
  readonly sessionId?: string;
33
+ /** Trusted user boundary; never accepted from agent tool arguments. */
34
+ readonly access?: "member";
33
35
  }
34
36
  export interface ReceiveAttachment {
35
37
  readonly source: (typeof ATTACHMENT_SOURCES)[number];
@@ -50,6 +52,16 @@ export interface AttachmentView {
50
52
  readonly createdAt: string;
51
53
  readonly expiresAt: string;
52
54
  readonly promotion: unknown;
55
+ readonly conversation?: string;
56
+ readonly senderId?: string;
57
+ }
58
+ export interface AttachmentSearch {
59
+ readonly conversation?: string;
60
+ readonly senderId?: string;
61
+ readonly from?: string;
62
+ readonly to?: string;
63
+ readonly before?: string;
64
+ readonly limit?: number;
53
65
  }
54
66
  export interface PromotionTarget {
55
67
  /** The entity whose action adopts the file, such as `document`. */
@@ -103,6 +115,20 @@ export declare function attachmentRuntime(ontology: Ontology, sql: SqlClient.Sql
103
115
  deduplicated: boolean;
104
116
  result: {} | null;
105
117
  }, unknown, never>;
118
+ listForMember: (filters: AttachmentSearch, principal: Principal) => Effect.Effect<{
119
+ attachments: AttachmentView[];
120
+ nextCursor: string | null;
121
+ }, PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
122
+ getForMember: (id: string, principal: Principal) => Effect.Effect<AttachmentView, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
123
+ readForMember: (id: string, principal: Principal) => Effect.Effect<{
124
+ attachment: AttachmentView;
125
+ download: TransferGrant;
126
+ }, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
127
+ promoteForMember: (id: string, target: PromotionTarget, principal: Principal, run: PromotionRunner) => Effect.Effect<{
128
+ attachment: AttachmentView;
129
+ deduplicated: boolean;
130
+ result: {} | null;
131
+ }, unknown, never>;
106
132
  };
107
133
  export type AttachmentRuntime = ReturnType<typeof attachmentRuntime>;
108
134
  export type AttachmentFailure = ActionRejected | Conflict | NotFound | PermissionDenied;
@@ -96,6 +96,8 @@ function view(row) {
96
96
  createdAt: row.created_at,
97
97
  expiresAt: row.expires_at,
98
98
  promotion: row.promotion ?? null,
99
+ conversation: row.conversation,
100
+ senderId: row.owner_id,
99
101
  };
100
102
  }
101
103
  /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
@@ -110,6 +112,14 @@ export function attachmentRuntime(ontology, sql, provider) {
110
112
  return "denied";
111
113
  }
112
114
  yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
115
+ if (caller.access === "member") {
116
+ const admin = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
117
+ "agent_chat:update",
118
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
119
+ if (admin) {
120
+ return "administrator";
121
+ }
122
+ }
113
123
  const separator = caller.conversation.indexOf(":");
114
124
  const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
115
125
  WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
@@ -148,19 +158,21 @@ export function attachmentRuntime(ontology, sql, provider) {
148
158
  caller.principal.id,
149
159
  caller.conversation,
150
160
  binding.branchId,
151
- access === "group" && scope === "session",
161
+ (access === "group" || access === "administrator") && scope === "session",
152
162
  ]);
153
163
  const row = rows[0];
154
164
  if (!row) {
155
165
  return yield* missing(id);
156
166
  }
157
167
  if (access === "private" &&
168
+ caller.access !== "member" &&
158
169
  scope === "session" &&
159
170
  row.session_id !== null &&
160
171
  row.session_id !== caller.sessionId) {
161
172
  return yield* missing(id);
162
173
  }
163
174
  if (access === "private" &&
175
+ caller.access !== "member" &&
164
176
  scope === "session" &&
165
177
  row.session_id === null &&
166
178
  caller.sessionId !== undefined) {
@@ -401,6 +413,9 @@ export function attachmentRuntime(ontology, sql, provider) {
401
413
  const prepared = yield* sql.withTransaction(Effect.gen(function* () {
402
414
  const row = yield* load(id, caller);
403
415
  yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
416
+ if (caller.access === "member" && row.expired) {
417
+ return yield* unavailable("The attachment expired or was purged.");
418
+ }
404
419
  if (row.state === "promoted") {
405
420
  return { kind: "done", row };
406
421
  }
@@ -419,7 +434,7 @@ export function attachmentRuntime(ontology, sql, provider) {
419
434
  WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
420
435
  AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
421
436
  AND conversation = $5 AND (owner_id = $6::uuid OR $7)
422
- AND ($7 OR session_id IS NULL OR session_id = $8)
437
+ AND ($7 OR $9 OR session_id IS NULL OR session_id = $8)
423
438
  ORDER BY created_at LIMIT 1`, [
424
439
  row.sha256,
425
440
  binding.branchId,
@@ -427,8 +442,9 @@ export function attachmentRuntime(ontology, sql, provider) {
427
442
  qualified,
428
443
  caller.conversation,
429
444
  caller.principal.id,
430
- (yield* accessScope(caller)) === "group",
445
+ ["group", "administrator"].includes(yield* accessScope(caller)),
431
446
  caller.sessionId ?? null,
447
+ caller.access === "member",
432
448
  ]);
433
449
  if (earlier) {
434
450
  const promotion = {
@@ -528,7 +544,106 @@ export function attachmentRuntime(ontology, sql, provider) {
528
544
  }));
529
545
  return { attachment: view(promoted), deduplicated: false, result: outcome.result ?? null };
530
546
  });
531
- return { receive, complete, list, read, discard, promote };
547
+ /** Resolve the chat from storage, then authorize with the same locked loader. */
548
+ const memberCaller = (id, principal) => sql.withTransaction(Effect.gen(function* () {
549
+ if (!/^[0-9a-f-]{36}$/i.test(id)) {
550
+ return yield* missing(id);
551
+ }
552
+ const [row] = yield* sql.unsafe(`SELECT conversation FROM "${ATTACHMENTS_TABLE}" WHERE id = $1::uuid AND branch_id = $2`, [id, binding.branchId]);
553
+ if (!row) {
554
+ return yield* missing(id);
555
+ }
556
+ const caller = {
557
+ principal,
558
+ conversation: row.conversation,
559
+ access: "member",
560
+ };
561
+ yield* load(id, caller);
562
+ return caller;
563
+ }));
564
+ const getForMember = (id, principal) => Effect.gen(function* () {
565
+ const caller = yield* memberCaller(id, principal);
566
+ const row = yield* sql.withTransaction(load(id, caller));
567
+ if (!READABLE.has(row.state) || row.expired) {
568
+ return yield* unavailable("The attachment expired, was purged, or is not ready.");
569
+ }
570
+ return view(row);
571
+ });
572
+ const readForMember = (id, principal) => Effect.gen(function* () {
573
+ return yield* read(id, yield* memberCaller(id, principal));
574
+ });
575
+ const promoteForMember = (id, target, principal, run) => Effect.gen(function* () {
576
+ return yield* promote(id, target, yield* memberCaller(id, principal), run);
577
+ });
578
+ const listForMember = (filters, principal) => sql.withTransaction(Effect.gen(function* () {
579
+ const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [principal.id]);
580
+ if (member?.kind !== "member" || !member.active) {
581
+ return yield* missing("chat");
582
+ }
583
+ yield* authorizeBlobOperation(ontology, sql, principal.id, []);
584
+ const admin = yield* authorizeBlobOperation(ontology, sql, principal.id, [
585
+ "agent_chat:update",
586
+ ]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
587
+ if (filters.conversation !== undefined) {
588
+ const scope = yield* accessScope({
589
+ principal,
590
+ conversation: filters.conversation,
591
+ access: "member",
592
+ });
593
+ if (scope === "denied") {
594
+ return yield* missing("chat");
595
+ }
596
+ if (scope === "private") {
597
+ const [sent] = yield* sql.unsafe(`SELECT 1 FROM "${ATTACHMENTS_TABLE}" WHERE branch_id = $1 AND conversation = $2
598
+ AND owner_id = $3::uuid LIMIT 1`, [binding.branchId, filters.conversation, principal.id]);
599
+ if (sent === undefined) {
600
+ return yield* missing("chat");
601
+ }
602
+ }
603
+ }
604
+ const limit = filters.limit ?? 50;
605
+ const rows = yield* sql.unsafe(`${SELECT} WHERE a.branch_id = $1
606
+ AND ($2 OR EXISTS (
607
+ SELECT 1 FROM "agent_chat" c JOIN "agent_chat_members" m ON m.agent_chat_id = c.id
608
+ WHERE c.provider || ':' || c.external_id = a.conversation AND m.user_id = $3::uuid
609
+ ) OR (a.owner_id = $3::uuid AND NOT EXISTS (
610
+ SELECT 1 FROM "agent_chat" c WHERE c.provider || ':' || c.external_id = a.conversation
611
+ )))
612
+ AND ($4::text IS NULL OR a.conversation = $4)
613
+ AND ($5::uuid IS NULL OR a.owner_id = $5::uuid)
614
+ AND ($6::timestamptz IS NULL OR a.created_at >= $6::timestamptz)
615
+ AND ($7::timestamptz IS NULL OR a.created_at <= $7::timestamptz)
616
+ AND ($8::uuid IS NULL OR (a.created_at, a.id) < (
617
+ SELECT created_at, id FROM "${ATTACHMENTS_TABLE}" WHERE id = $8::uuid AND branch_id = $1
618
+ ))
619
+ ORDER BY a.created_at DESC, a.id DESC LIMIT $9`, [
620
+ binding.branchId,
621
+ admin,
622
+ principal.id,
623
+ filters.conversation ?? null,
624
+ filters.senderId ?? null,
625
+ filters.from ?? null,
626
+ filters.to ?? null,
627
+ filters.before ?? null,
628
+ limit + 1,
629
+ ]);
630
+ return {
631
+ attachments: rows.slice(0, limit).map(view),
632
+ nextCursor: rows.length > limit ? rows[limit - 1].id : null,
633
+ };
634
+ }));
635
+ return {
636
+ receive,
637
+ complete,
638
+ list,
639
+ read,
640
+ discard,
641
+ promote,
642
+ listForMember,
643
+ getForMember,
644
+ readForMember,
645
+ promoteForMember,
646
+ };
532
647
  }
533
648
  /**
534
649
  * Expires attachments past their deadline and removes the bytes of every expired or discarded one
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListMultipartUploadsCommand, ListPartsCommand, S3Client, UploadPartCommand, } from "@aws-sdk/client-s3";
2
3
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3
4
  import { Effect } from "effect";
@@ -168,6 +169,53 @@ export function neonBlobProvider(binding) {
168
169
  throw error;
169
170
  }
170
171
  }),
172
+ sha256: (key, size) => attempt("blob.sha256", async () => {
173
+ const response = await client.send(new GetObjectCommand(object(key)));
174
+ if (!response.Body) {
175
+ throw new Error("Neon returned no object body.");
176
+ }
177
+ const hash = createHash("sha256");
178
+ const reader = response.Body.transformToWebStream().getReader();
179
+ let received = 0;
180
+ let failure;
181
+ try {
182
+ for (;;) {
183
+ const { done, value } = await reader.read();
184
+ if (done) {
185
+ break;
186
+ }
187
+ received += value.byteLength;
188
+ if (received > size) {
189
+ throw new Error("The stored object exceeds its declared size.");
190
+ }
191
+ hash.update(value);
192
+ }
193
+ if (received !== size) {
194
+ throw new Error("The stored object does not match its declared size.");
195
+ }
196
+ return hash.digest("hex");
197
+ }
198
+ catch (cause) {
199
+ failure = cause;
200
+ throw cause;
201
+ }
202
+ finally {
203
+ try {
204
+ await reader.cancel();
205
+ }
206
+ catch (cleanup) {
207
+ if (failure !== undefined) {
208
+ throw new AggregateError([failure, cleanup], "Hashing and stream cleanup failed.", {
209
+ cause: cleanup,
210
+ });
211
+ }
212
+ throw cleanup;
213
+ }
214
+ finally {
215
+ reader.releaseLock();
216
+ }
217
+ }
218
+ }),
171
219
  readPrefix: (key) => attempt("blob.read_prefix", async () => {
172
220
  const response = await client.send(new GetObjectCommand({ ...object(key), Range: "bytes=0-511" }));
173
221
  if (!response.Body) {
@@ -60,6 +60,8 @@ declare const BlobProvider_base: Context.ServiceClass<BlobProvider, "@segmentstr
60
60
  }>;
61
61
  }) => Effect.Effect<void, BlobStorageError>;
62
62
  readonly inspect: (key: string) => Effect.Effect<StoredObject | undefined, BlobStorageError>;
63
+ /** Hash immutable content without buffering it. */
64
+ readonly sha256: (key: string, size: number) => Effect.Effect<string, BlobStorageError>;
63
65
  readonly readPrefix: (key: string) => Effect.Effect<Uint8Array, BlobStorageError>;
64
66
  /** The whole object, at most `maxBytes`; for verifying an agent attachment's digest. */
65
67
  readonly read: (key: string, maxBytes: number) => Effect.Effect<Uint8Array, BlobStorageError>;
@@ -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: typeof DownloadRequest.Type, principal: Principal) => Effect.Effect<import("./provider.js").TransferGrant, import("../errors.js").PermissionDenied | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
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>;
@@ -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 download = (entityName, recordId, propertyName, input, principal) => Effect.gen(function* () {
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 FROM "_blobs" b JOIN "_blob_references" r ON r.blob_id = b.id
323
- WHERE b.id = $1::uuid AND b.state = 'retained' AND r.entity_id = $2
324
- AND r.property_id = $3 AND r.record_id = $4::uuid FOR SHARE OF b`, [revisionId ?? null, entity.id, property.name, recordId]);
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: input.inline === true &&
343
+ inline: args[3].inline === true &&
339
344
  ["image/jpeg", "image/png"].includes(blob.content_type.toLowerCase()),
340
345
  });
341
346
  });
342
- return { begin, status, part, complete, cancel, download };
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.0",
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",