ankka 0.11.0 → 0.13.0

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.
Files changed (92) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +910 -0
  3. package/dist/_proto/ankka/protocol/v1/client_pb.js +239 -23
  4. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  5. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  6. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +177 -0
  7. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
  8. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +26 -0
  9. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +15 -10
  10. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  11. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  12. package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
  13. package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +7 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/agent.d.ts +10 -0
  21. package/dist/agent.js +20 -0
  22. package/dist/autonomous.d.ts +5 -0
  23. package/dist/autonomous.js +10 -0
  24. package/dist/client-keys.d.ts +89 -0
  25. package/dist/client-keys.js +182 -0
  26. package/dist/client.d.ts +96 -3
  27. package/dist/client.js +228 -6
  28. package/dist/consumer.d.ts +23 -1
  29. package/dist/consumer.js +25 -1
  30. package/dist/context.d.ts +25 -1
  31. package/dist/context.js +5 -2
  32. package/dist/contract.d.ts +4 -0
  33. package/dist/contract.js +2 -0
  34. package/dist/effects/common.d.ts +7 -0
  35. package/dist/effects/common.js +9 -1
  36. package/dist/effects/eventSourced.d.ts +26 -1
  37. package/dist/effects/eventSourced.js +58 -5
  38. package/dist/endpoint.d.ts +10 -0
  39. package/dist/endpoint.js +20 -0
  40. package/dist/eventSourcedEntity.d.ts +8 -3
  41. package/dist/graph.d.ts +14 -1
  42. package/dist/graph.js +17 -1
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.js +8 -2
  45. package/dist/keyedView.d.ts +4 -1
  46. package/dist/keyedView.js +7 -1
  47. package/dist/kinds.js +2 -0
  48. package/dist/mcp-endpoint.d.ts +144 -0
  49. package/dist/mcp-endpoint.js +256 -0
  50. package/dist/routes.d.ts +46 -3
  51. package/dist/routes.js +56 -3
  52. package/dist/server/discovery.d.ts +12 -0
  53. package/dist/server/discovery.js +115 -4
  54. package/dist/server/eventSourced.d.ts +4 -1
  55. package/dist/server/eventSourced.js +24 -6
  56. package/dist/server/http.d.ts +8 -0
  57. package/dist/server/http.js +12 -4
  58. package/dist/server/mcp.d.ts +17 -0
  59. package/dist/server/mcp.js +157 -0
  60. package/dist/server/server.d.ts +11 -1
  61. package/dist/server/server.js +19 -0
  62. package/dist/server/stateless.js +5 -4
  63. package/dist/server/storageEndpoint.d.ts +14 -0
  64. package/dist/server/storageEndpoint.js +82 -0
  65. package/dist/service.d.ts +12 -1
  66. package/dist/service.js +73 -5
  67. package/dist/sigv4.d.ts +47 -0
  68. package/dist/sigv4.js +149 -0
  69. package/dist/spec.d.ts +8 -2
  70. package/dist/spec.js +62 -6
  71. package/dist/standing.d.ts +18 -0
  72. package/dist/standing.js +26 -0
  73. package/dist/startFrom.d.ts +4 -0
  74. package/dist/startFrom.js +8 -1
  75. package/dist/storage.d.ts +288 -0
  76. package/dist/storage.js +1081 -0
  77. package/dist/testkit/index.d.ts +1 -1
  78. package/dist/testkit/integration.d.ts +9 -0
  79. package/dist/testkit/integration.js +5 -1
  80. package/dist/testkit/kinds.d.ts +25 -9
  81. package/dist/testkit/kinds.js +53 -28
  82. package/dist/testkit/unit.d.ts +31 -3
  83. package/dist/testkit/unit.js +55 -11
  84. package/dist/timedAction.d.ts +10 -0
  85. package/dist/timedAction.js +20 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/dist/view.d.ts +18 -3
  89. package/dist/view.js +13 -4
  90. package/dist/workflow.d.ts +8 -0
  91. package/dist/workflow.js +15 -0
  92. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import type { Registry } from "../service.ts";
1
+ import { Registry } from "../service.ts";
2
2
  import type { ComponentClient } from "../client.ts";
3
+ import { type Buckets } from "../storage.ts";
3
4
  export interface ServerOptions {
4
5
  /** `127.0.0.1` by default; `0.0.0.0` for the testkit; anything else is refused. */
5
6
  readonly host?: string;
@@ -8,12 +9,21 @@ export interface ServerOptions {
8
9
  /** The largest message accepted from the sidecar; 64 MiB by default. */
9
10
  readonly readMaxBytes?: number;
10
11
  readonly log?: (message: string) => void;
12
+ /**
13
+ * The service's buckets, in place of the store `ANKKA_STORAGE_BACKEND` and the `ANKKA_S3_*` variables
14
+ * choose: the integration testkit's in-memory store.
15
+ */
16
+ readonly storage?: Buckets;
17
+ /** Where `service.json` is read from for the buckets it declares; the working directory by default. */
18
+ readonly serviceDirectory?: string;
11
19
  }
12
20
  /** What every servicer needs. */
13
21
  export interface ServerContext {
14
22
  readonly registry: Registry;
15
23
  readonly client: ComponentClient;
16
24
  readonly log: (message: string) => void;
25
+ /** The buckets every endpoint instance is given; the process's store when absent. */
26
+ readonly storage?: Buckets;
17
27
  }
18
28
  export declare class Server {
19
29
  #private;
@@ -4,6 +4,9 @@
4
4
  // the integration testkit, whose sidecar reaches this process from a container.
5
5
  import { createServer } from "node:http2";
6
6
  import { connectNodeAdapter } from "@connectrpc/connect-node";
7
+ import { Registry } from "../service.js";
8
+ import { _setProcessStorage, chooseStorage, DirectoryBuckets } from "../storage.js";
9
+ import { DirectoryStorageEndpoint } from "./storageEndpoint.js";
7
10
  import { renderSpec } from "../spec.js";
8
11
  import { discoveryRoutes } from "./discovery.js";
9
12
  import { eventSourcedRoutes } from "./eventSourced.js";
@@ -12,6 +15,13 @@ import { workflowRoutes } from "./workflow.js";
12
15
  import { statelessRoutes } from "./stateless.js";
13
16
  import { agentRoutes } from "./agent.js";
14
17
  import { httpRoutes } from "./http.js";
18
+ import { mcpRoutes } from "./mcp.js";
19
+ /** The registry with the directory store's endpoint, which serves the URLs that store signs. */
20
+ function withStorageEndpoint(registry) {
21
+ const cls = DirectoryStorageEndpoint;
22
+ const endpoint = Object.freeze({ id: cls.name, cls, prefix: cls.prefix, acl: cls.acl, routes: new Map(Object.entries(cls.routes)) });
23
+ return new Registry(registry.components, new Map([...registry.endpoints, [endpoint.id, endpoint]]));
24
+ }
15
25
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0", "::"]);
16
26
  export class Server {
17
27
  #context;
@@ -39,6 +49,14 @@ export class Server {
39
49
  const port = this.#options.port ?? Number(process.env.ANKKA_PROCESS_PORT ?? 9010);
40
50
  if (!Number.isInteger(port) || port < 0 || port > 65535)
41
51
  throw new Error(`not a port: ${String(port)}`);
52
+ // The service's buckets: the store given, or the one the environment chooses. A directory store's
53
+ // URLs are answered by an endpoint this process declares at /_ankka/storage.
54
+ const given = this.#options.storage;
55
+ const chosen = given ? { backend: "given", buckets: given } : chooseStorage(process.env, { ...(this.#options.serviceDirectory ? { serviceDirectory: this.#options.serviceDirectory } : {}) });
56
+ _setProcessStorage(chosen.buckets);
57
+ const registry = chosen.buckets instanceof DirectoryBuckets ? withStorageEndpoint(this.#context.registry) : this.#context.registry;
58
+ this.#context = { ...this.#context, registry, storage: chosen.buckets };
59
+ this.#context.log(`ankka: object storage is ${chosen.backend}${chosen.buckets instanceof DirectoryBuckets ? ` at ${chosen.buckets.root}` : ""}`);
42
60
  const handler = connectNodeAdapter({
43
61
  routes: (router) => {
44
62
  discoveryRoutes(router, () => renderSpec(this.#context.registry), this.#context.log);
@@ -48,6 +66,7 @@ export class Server {
48
66
  statelessRoutes(router, this.#context);
49
67
  agentRoutes(router, this.#context);
50
68
  httpRoutes(router, this.#context);
69
+ mcpRoutes(router, this.#context);
51
70
  },
52
71
  shutdownSignal: this.#shutdown.signal,
53
72
  readMaxBytes: this.#options.readMaxBytes ?? 64 * 1024 * 1024,
@@ -15,6 +15,7 @@ import { JSON_CONTENT } from "../codec.js";
15
15
  import { errorCodeToProto } from "../kinds.js";
16
16
  import { decodePayload, encodePayload } from "./payloads.js";
17
17
  import { PayloadSchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
18
+ import { standingFromProto } from "../standing.js";
18
19
  function messageOf(e) {
19
20
  return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
20
21
  }
@@ -33,7 +34,7 @@ async function handleKeyedView(req, sourceId, ctx) {
33
34
  const view = new registered.cls();
34
35
  try {
35
36
  const client = ctx.client.withMetadata(metadata);
36
- view._bind(metadata, client, clientRows(client, registered.id, registered.rowCodec));
37
+ view._bind(metadata, client, clientRows(client, registered.id, registered.rowCodec), standingFromProto(req.standing));
37
38
  const effect = (req.deleted
38
39
  ? source.deleted
39
40
  ? await source.deleted(view)
@@ -63,7 +64,7 @@ export async function handleView(req, ctx) {
63
64
  const view = new registered.cls();
64
65
  try {
65
66
  const row = req.row ? decodePayload(registered.rowCodec, req.row) : null;
66
- view._bind(row, metadata, ctx.client.withMetadata(metadata));
67
+ view._bind(row, metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
67
68
  const effect = req.deleted ? await view.onDelete() : await view.onChange(decodePayload(registered.eventCodec, req.event));
68
69
  switch (effect.kind) {
69
70
  case "update-row":
@@ -102,7 +103,7 @@ function produceAll(request, messages) {
102
103
  }
103
104
  async function graphEffect(registered, req, metadata, ctx) {
104
105
  const consumer = new registered.cls();
105
- consumer._bind(metadata, ctx.client.withMetadata(metadata));
106
+ consumer._bind(metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
106
107
  const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
107
108
  switch (effect.kind) {
108
109
  case "publish":
@@ -121,7 +122,7 @@ async function graphEffect(registered, req, metadata, ctx) {
121
122
  }
122
123
  async function consumerEffect(registered, req, metadata, ctx) {
123
124
  const consumer = new registered.cls();
124
- consumer._bind(metadata, ctx.client.withMetadata(metadata));
125
+ consumer._bind(metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
125
126
  const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
126
127
  const outCodec = () => {
127
128
  if (!registered.outCodec)
@@ -0,0 +1,14 @@
1
+ import { Endpoint } from "../endpoint.ts";
2
+ import { Acl, RawReply, type RouteRef } from "../routes.ts";
3
+ type Params = Readonly<Record<string, string>>;
4
+ export declare class DirectoryStorageEndpoint extends Endpoint {
5
+ #private;
6
+ static readonly prefix = "/_ankka/storage";
7
+ static readonly acl: Acl;
8
+ /** @internal The SDK's own endpoint under `/_ankka`, which registration admits. */
9
+ static readonly _sdkReserved = true;
10
+ static readonly routes: Readonly<Record<string, RouteRef<DirectoryStorageEndpoint, any, any, any>>>;
11
+ read(params: Params, depth: number): Promise<RawReply>;
12
+ keep(params: Params, depth: number, body: Uint8Array): Promise<RawReply>;
13
+ }
14
+ export {};
@@ -0,0 +1,82 @@
1
+ // What a TypeScript service on a developer's machine serves at `/_ankka/storage/<bucket>/<name>` while its
2
+ // store is a directory (feature 055, research R12): the signed URLs and public addresses that store
3
+ // makes, answered through the sidecar like any route, so a browser reads and keeps through them as it
4
+ // would through an object store's. The runtime's `DirectoryStorageEndpoint` says the same in Scala.
5
+ //
6
+ // The token is the whole of the authority, as a signature is on an object store: a `GET` reads with a
7
+ // token for `read`, or with none on a public bucket; a `PUT` keeps with a token for `keep`. Anything
8
+ // else is `403`, and an object not there `404`. The sidecar matches a route by whole segments and an
9
+ // object's name holds slashes, so one route is declared per depth, up to `MAX_ADDRESS_SEGMENTS`; a
10
+ // deeper name travels as one segment with its slashes encoded.
11
+ import { binaryCodecs } from "../codec.js";
12
+ import { Endpoint } from "../endpoint.js";
13
+ import { Acl, get, HttpProblem, put, RawReply } from "../routes.js";
14
+ import { DirectoryBuckets, MAX_ADDRESS_SEGMENTS, STORAGE_PATH } from "../storage.js";
15
+ /** A segment as the sidecar forwards it, encoded; one that is not valid encoding is taken as it is. */
16
+ function decoded(segment) {
17
+ try {
18
+ return decodeURIComponent(segment);
19
+ }
20
+ catch {
21
+ return segment;
22
+ }
23
+ }
24
+ /** The bucket's segment and the object's name a request's path parameters spell. */
25
+ function addressed(params, depth) {
26
+ const parts = [];
27
+ for (let i = 1; i <= depth; i++)
28
+ parts.push(decoded(params[`n${i}`] ?? ""));
29
+ return { segment: decoded(params.bucket ?? ""), objectName: parts.join("/") };
30
+ }
31
+ const NOWHERE = "no object at this address";
32
+ const UNSIGNED = "this address is not signed for that, or its signature has expired";
33
+ export class DirectoryStorageEndpoint extends Endpoint {
34
+ static prefix = STORAGE_PATH;
35
+ static acl = Acl.allowAll;
36
+ /** @internal The SDK's own endpoint under `/_ankka`, which registration admits. */
37
+ static _sdkReserved = true;
38
+ static routes = (() => {
39
+ const table = {};
40
+ for (let depth = 1; depth <= MAX_ADDRESS_SEGMENTS; depth++) {
41
+ const template = "/{bucket}" + Array.from({ length: depth }, (_, i) => `/{n${i + 1}}`).join("");
42
+ table[`read${depth}`] = get(template, binaryCodecs.bytes, (ep, req) => ep.read(req.params, depth));
43
+ table[`keep${depth}`] = put(template, binaryCodecs.bytes, binaryCodecs.bytes, (ep, req, body) => ep.keep(req.params, depth, body));
44
+ }
45
+ return Object.freeze(table);
46
+ })();
47
+ #store(segment) {
48
+ const store = this.storage;
49
+ if (!(store instanceof DirectoryBuckets))
50
+ throw new HttpProblem(404, NOWHERE);
51
+ const found = store.bySegment(segment);
52
+ if (!found)
53
+ throw new HttpProblem(404, NOWHERE);
54
+ return { store, bucket: found.bucket, segment: found.segment };
55
+ }
56
+ #signedFor(store, segment, objectName, access) {
57
+ const q = this.request.query;
58
+ const expires = Number(q.get("expires") ?? "");
59
+ const token = q.get("token");
60
+ return q.get("access") === access && token !== null && Number.isInteger(expires) && store.admits(segment, objectName, access, expires, token);
61
+ }
62
+ async read(params, depth) {
63
+ const { segment, objectName } = addressed(params, depth);
64
+ const { store, bucket } = this.#store(segment);
65
+ const open = bucket.exposure === "public" && bucket.name !== undefined;
66
+ if (!open && !this.#signedFor(store, segment, objectName, "read"))
67
+ throw new HttpProblem(403, UNSIGNED);
68
+ const object = await bucket.get(objectName);
69
+ if (!object)
70
+ throw new HttpProblem(404, `no object '${objectName}'`);
71
+ return new RawReply(200, object.contentType, await object.bytes());
72
+ }
73
+ async keep(params, depth, body) {
74
+ const { segment, objectName } = addressed(params, depth);
75
+ const { store, bucket } = this.#store(segment);
76
+ if (!this.#signedFor(store, segment, objectName, "keep"))
77
+ throw new HttpProblem(403, UNSIGNED);
78
+ const contentType = this.request.headers.get("content-type") ?? "application/octet-stream";
79
+ await bucket.put(objectName, body, { contentType });
80
+ return new RawReply(200, "text/plain; charset=utf-8", new Uint8Array());
81
+ }
82
+ }
package/dist/service.d.ts CHANGED
@@ -13,6 +13,7 @@ import { Agent, type AgentClass } from "./agent.ts";
13
13
  import { type McpServerRef, type ResultGuardrailRef } from "./mcp.ts";
14
14
  import { AutonomousAgent, type AutonomousAgentClass, type TaskType } from "./autonomous.ts";
15
15
  import { Endpoint, type EndpointClass } from "./endpoint.ts";
16
+ import { McpEndpoint, type McpEndpointClass, type RegisteredMcpEndpoint } from "./mcp-endpoint.ts";
16
17
  import type { GuardrailRef, HandlerRef, ToolRef } from "./handlers.ts";
17
18
  import type { RouteRef, Acl } from "./routes.ts";
18
19
  import { ComponentClient } from "./client.ts";
@@ -28,6 +29,8 @@ export interface RegisteredEventSourced {
28
29
  readonly eventCodec: Codec<any>;
29
30
  readonly handlers: ReadonlyMap<string, HandlerRef<any, any, any, any>>;
30
31
  readonly snapshotEvery: number;
32
+ /** Declared replicated (1.19). */
33
+ readonly replicated: boolean;
31
34
  }
32
35
  export interface RegisteredKeyValue {
33
36
  readonly kind: "key-value";
@@ -58,6 +61,8 @@ export type Source = {
58
61
  readonly contract?: Contract;
59
62
  /** The declared broker the topic is on; absent is the installation's. */
60
63
  readonly broker?: string;
64
+ /** Another project's topic: the project whose topic it is (1.15). */
65
+ readonly project?: string;
61
66
  /** Partitions handled at once, each in order. */
62
67
  readonly parallel?: boolean;
63
68
  };
@@ -102,6 +107,8 @@ export interface RegisteredConsumer {
102
107
  /** A graph consumer: its handlers return elements, each published as a delta under its element key. */
103
108
  readonly graph: boolean;
104
109
  readonly version: number | undefined;
110
+ /** Given a replicated entity's mirrored changes too (1.19). */
111
+ readonly mirroredChanges: boolean;
105
112
  }
106
113
  export interface RegisteredTimedAction {
107
114
  readonly kind: "timed-action";
@@ -144,7 +151,10 @@ export interface RegisteredEndpoint {
144
151
  export declare class Registry {
145
152
  readonly components: ReadonlyMap<string, RegisteredComponent>;
146
153
  readonly endpoints: ReadonlyMap<string, RegisteredEndpoint>;
147
- constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>);
154
+ /** The MCP endpoints (1.20), by id, their references resolved. */
155
+ readonly mcpEndpoints: ReadonlyMap<string, RegisteredMcpEndpoint>;
156
+ constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>, mcpEndpoints?: ReadonlyMap<string, RegisteredMcpEndpoint>);
157
+ mcpEndpoint(id: string): RegisteredMcpEndpoint | undefined;
148
158
  component(id: string): RegisteredComponent | undefined;
149
159
  /** The component `id` when it is of `kind`. */
150
160
  of<K extends RegisteredComponent["kind"]>(kind: K, id: string): Extract<RegisteredComponent, {
@@ -179,6 +189,7 @@ export declare class ServiceBuilder {
179
189
  register<C extends TimedAction>(cls: TimedActionClass<C>): this;
180
190
  register<C extends Agent>(cls: AgentClass<C>): this;
181
191
  register<C extends AutonomousAgent>(cls: AutonomousAgentClass<C>): this;
192
+ register<C extends McpEndpoint>(cls: McpEndpointClass<C>): this;
182
193
  register<C extends Endpoint>(cls: EndpointClass<C>): this;
183
194
  /** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
184
195
  validate(): Registry;
package/dist/service.js CHANGED
@@ -18,7 +18,8 @@ import { Agent } from "./agent.js";
18
18
  import { serverProblems, TOOL_PREFIX } from "./mcp.js";
19
19
  import { AutonomousAgent, COMPLETE_TASK, FAIL_TASK } from "./autonomous.js";
20
20
  import { Endpoint } from "./endpoint.js";
21
- import { isAcl } from "./routes.js";
21
+ import { McpEndpoint, registerMcpEndpoint } from "./mcp-endpoint.js";
22
+ import { isAcl, isEndpointAcl } from "./routes.js";
22
23
  import { ComponentClient } from "./client.js";
23
24
  import { Server } from "./server/server.js";
24
25
  import { renderSpec } from "./spec.js";
@@ -26,11 +27,17 @@ import { renderSpec } from "./spec.js";
26
27
  export class Registry {
27
28
  components;
28
29
  endpoints;
29
- constructor(components, endpoints) {
30
+ /** The MCP endpoints (1.20), by id, their references resolved. */
31
+ mcpEndpoints;
32
+ constructor(components, endpoints, mcpEndpoints = new Map()) {
30
33
  this.components = components;
31
34
  this.endpoints = endpoints;
35
+ this.mcpEndpoints = mcpEndpoints;
32
36
  Object.freeze(this);
33
37
  }
38
+ mcpEndpoint(id) {
39
+ return this.mcpEndpoints.get(id);
40
+ }
34
41
  component(id) {
35
42
  return this.components.get(id);
36
43
  }
@@ -83,6 +90,7 @@ export class ServiceBuilder {
83
90
  const components = new Map();
84
91
  const endpoints = new Map();
85
92
  const prefixes = new Map();
93
+ const mcpClasses = [];
86
94
  const add = (r, cls) => {
87
95
  if (!r)
88
96
  return;
@@ -112,6 +120,9 @@ export class ServiceBuilder {
112
120
  add(registerAgent(cls, problems), cls);
113
121
  else if (extendsBase(cls, AutonomousAgent))
114
122
  add(registerAutonomousAgent(cls, problems), cls);
123
+ // Before Endpoint: an MCP endpoint is one, served by the platform rather than by routes.
124
+ else if (extendsBase(cls, McpEndpoint))
125
+ mcpClasses.push(cls);
115
126
  else if (extendsBase(cls, Endpoint)) {
116
127
  const ep = cls;
117
128
  if (typeof ep.prefix === "string") {
@@ -129,12 +140,35 @@ export class ServiceBuilder {
129
140
  }
130
141
  }
131
142
  else {
132
- problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, KeyedView, Consumer, GraphConsumer, TimedAction, Agent, AutonomousAgent or Endpoint`);
143
+ problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, KeyedView, Consumer, GraphConsumer, TimedAction, Agent, AutonomousAgent, Endpoint or McpEndpoint`);
144
+ }
145
+ }
146
+ // MCP endpoints last: their references name the components and endpoints registered above.
147
+ const mcpEndpoints = new Map();
148
+ const mcpPaths = new Map();
149
+ for (const cls of mcpClasses) {
150
+ const r = registerMcpEndpoint(cls, components, endpoints, problems);
151
+ if (!r)
152
+ continue;
153
+ const other = mcpPaths.get(r.path);
154
+ if (other)
155
+ problems.push(`MCP endpoints ${other} and ${r.id} share the path ${r.path}`);
156
+ mcpPaths.set(r.path, r.id);
157
+ for (const e of endpoints.values()) {
158
+ for (const route of e.routes.values()) {
159
+ const full = route.template === "/" ? e.prefix : e.prefix === "/" ? route.template : `${e.prefix}${route.template}`;
160
+ if (full === r.path)
161
+ problems.push(`${r.id}: its path ${r.path} is also the route ${route.method} ${full} of ${e.id}`);
162
+ }
133
163
  }
164
+ if (mcpEndpoints.has(r.id))
165
+ problems.push(`two MCP endpoints are named ${r.id}`);
166
+ else
167
+ mcpEndpoints.set(r.id, r);
134
168
  }
135
169
  if (problems.length > 0)
136
170
  throw new RegistrationError(problems);
137
- return new Registry(components, endpoints);
171
+ return new Registry(components, endpoints, mcpEndpoints);
138
172
  }
139
173
  /** The discovery `Spec` for what is registered, without listening. */
140
174
  spec() {
@@ -191,7 +225,18 @@ function registerEventSourced(cls, problems) {
191
225
  const handlers = collectHandlers(cls.handlers, "handlers", ["command", "query"], fail);
192
226
  if (!ok())
193
227
  return undefined;
194
- return Object.freeze({ kind: "event-sourced", id: cls.componentId, cls, stateCodec: codecFor(cls.state), eventCodec: codecFor(cls.events), handlers, snapshotEvery });
228
+ if (cls.replicated !== undefined && typeof cls.replicated !== "boolean")
229
+ fail(`replicated must be true or false, not ${String(cls.replicated)}`);
230
+ return Object.freeze({
231
+ kind: "event-sourced",
232
+ id: cls.componentId,
233
+ cls,
234
+ stateCodec: codecFor(cls.state),
235
+ eventCodec: codecFor(cls.events),
236
+ handlers,
237
+ snapshotEvery,
238
+ replicated: cls.replicated === true,
239
+ });
195
240
  }
196
241
  function registerKeyValue(cls, problems) {
197
242
  const { fail, ok } = checker(cls, problems);
@@ -261,6 +306,12 @@ function topicProblems(cls, consumer, fail) {
261
306
  if ((cls.contract !== undefined || cls.broker !== undefined || cls.parallel !== undefined) && !readsTopic) {
262
307
  fail("declares a contract, a broker or parallel, which apply to a topic; it reads a component");
263
308
  }
309
+ if (cls.project !== undefined && (typeof cls.project !== "string" || cls.project.trim() === ""))
310
+ fail("project must be the id of the project whose topic this is");
311
+ if (cls.project !== undefined && !readsTopic)
312
+ fail("names a project, which applies to a topic; it reads a component");
313
+ if (cls.project !== undefined && cls.broker !== undefined)
314
+ fail("names a project and a broker; another project's topic is on the installation's broker");
264
315
  }
265
316
  function sourceOf(cls, fail, consumer) {
266
317
  const hasSource = cls.source !== undefined;
@@ -281,6 +332,7 @@ function sourceOf(cls, fail, consumer) {
281
332
  ...(cls.contract instanceof Contract ? { contract: cls.contract } : {}),
282
333
  ...(typeof cls.broker === "string" ? { broker: cls.broker } : {}),
283
334
  ...(cls.parallel === true ? { parallel: true } : {}),
335
+ ...(typeof cls.project === "string" ? { project: cls.project } : {}),
284
336
  };
285
337
  }
286
338
  const src = cls.source;
@@ -413,6 +465,7 @@ function registerConsumer(cls, problems) {
413
465
  produces,
414
466
  graph: false,
415
467
  version: cls.version,
468
+ mirroredChanges: cls.mirroredChanges === true,
416
469
  });
417
470
  }
418
471
  /**
@@ -436,10 +489,15 @@ function publicationOf(declared, fail) {
436
489
  fail("producesTo.contract must be a Contract: Contract.fromFile(path, name)");
437
490
  if (p.broker !== undefined && (typeof p.broker !== "string" || p.broker.trim() === ""))
438
491
  fail("producesTo.broker must be the name of a broker the project declares");
492
+ if (p.project !== undefined && (typeof p.project !== "string" || p.project.trim() === ""))
493
+ fail("producesTo.project must be the id of the project whose topic this is");
494
+ if (p.project !== undefined && p.broker !== undefined)
495
+ fail("producesTo names a project and a broker; another project's topic is on the installation's broker");
439
496
  return {
440
497
  topic: p.topic,
441
498
  ...(p.contract instanceof Contract ? { contract: p.contract } : {}),
442
499
  ...(typeof p.broker === "string" ? { broker: p.broker } : {}),
500
+ ...(typeof p.project === "string" ? { project: p.project } : {}),
443
501
  };
444
502
  }
445
503
  /** A graph consumer is a consumer to discovery and to the runtime: one that produces deltas to its topic. */
@@ -465,6 +523,7 @@ function registerGraphConsumer(cls, problems) {
465
523
  produces: undefined,
466
524
  graph: true,
467
525
  version: cls.version,
526
+ mirroredChanges: cls.mirroredChanges === true,
468
527
  });
469
528
  }
470
529
  function registerTimedAction(cls, problems) {
@@ -610,9 +669,16 @@ function registerEndpoint(cls, problems) {
610
669
  if (typeof cls.prefix !== "string" || !cls.prefix.startsWith("/") || (cls.prefix.length > 1 && cls.prefix.endsWith("/"))) {
611
670
  fail(`needs a static prefix starting with "/" and not ending with one, not ${JSON.stringify(cls.prefix)}`);
612
671
  }
672
+ else if ((cls.prefix === "/_ankka" || cls.prefix.startsWith("/_ankka/")) && cls._sdkReserved !== true) {
673
+ // The SDK's own directory-store endpoint is the one exception, as the sidecar admits it alone.
674
+ fail(`prefix '${cls.prefix}' is under /_ankka, which is the runtime's`);
675
+ }
613
676
  if (!isAcl(cls.acl)) {
614
677
  fail(`needs a static acl — Acl.allowAll, Acl.denyAll, Acl.authenticated or Acl.allowCallers(...). An unstated access rule is a decision nobody made`);
615
678
  }
679
+ else if (!isEndpointAcl(cls.acl)) {
680
+ fail("admits client keys or states Acl.anyOf, which only an MCP endpoint can: extend McpEndpoint");
681
+ }
616
682
  const routes = new Map();
617
683
  const seen = new Map();
618
684
  if (typeof cls.routes !== "object" || cls.routes === null) {
@@ -632,6 +698,8 @@ function registerEndpoint(cls, problems) {
632
698
  seen.set(key, id);
633
699
  if (r.acl !== undefined && !isAcl(r.acl))
634
700
  fail(`routes.${id}: unknown acl ${JSON.stringify(r.acl)}`);
701
+ else if (r.acl !== undefined && !isEndpointAcl(r.acl))
702
+ fail(`routes.${id} admits client keys or states Acl.anyOf, which only an MCP endpoint can`);
635
703
  routes.set(id, r);
636
704
  }
637
705
  }
@@ -0,0 +1,47 @@
1
+ /** A key pair. Never printed whole: `toString` hides the secret. */
2
+ export declare class Credentials {
3
+ #private;
4
+ readonly accessKey: string;
5
+ constructor(accessKey: string, secretKey: string);
6
+ /** @internal */
7
+ get _secretKey(): string;
8
+ toString(): string;
9
+ toJSON(): string;
10
+ }
11
+ export declare const ALGORITHM = "AWS4-HMAC-SHA256";
12
+ /** The payload hash a put sends so its body is streamed once and never hashed. */
13
+ export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
14
+ export declare function sha256Hex(data: Uint8Array | string): string;
15
+ export declare function hmac(key: Uint8Array | string, data: string): Buffer;
16
+ /** The hash of an empty payload. */
17
+ export declare const EMPTY_PAYLOAD: string;
18
+ /** RFC 3986 encoding as SigV4 states it: unreserved characters kept, everything else `%XX` of its UTF-8 bytes. */
19
+ export declare function encode(text: string, encodeSlash: boolean): string;
20
+ /** One request: the endpoint's scheme and authority, a decoded path and decoded query pairs. */
21
+ export declare class SignedRequest {
22
+ readonly method: string;
23
+ readonly endpoint: URL;
24
+ readonly path: string;
25
+ readonly query: readonly (readonly [string, string])[];
26
+ constructor(method: string, endpoint: URL | string, path: string, query?: readonly (readonly [string, string])[]);
27
+ /** The `host` header's value: the host, with the port when the endpoint names one. */
28
+ get host(): string;
29
+ get canonicalPath(): string;
30
+ get canonicalQuery(): string;
31
+ /** The URL the request is sent to: the same encoding as the one signed. */
32
+ get url(): string;
33
+ withQuery(more: readonly (readonly [string, string])[]): SignedRequest;
34
+ }
35
+ /** `20150830T123600Z`. */
36
+ export declare function amzDate(now: Date): string;
37
+ /**
38
+ * The headers to send with `request` so the store believes it: `x-amz-date` and `Authorization`.
39
+ * `headers` are the ones to sign beside `host` and `x-amz-date` — an S3 request includes
40
+ * `x-amz-content-sha256` among them — and are sent as given.
41
+ */
42
+ export declare function sign(request: SignedRequest, headers: Readonly<Record<string, string>>, payloadHash: string, credentials: Credentials, region: string, service: string, now: Date): {
43
+ "x-amz-date": string;
44
+ Authorization: string;
45
+ };
46
+ /** A URL anyone may use, until it expires, for `request`'s method on its path. Signs `host` alone. */
47
+ export declare function presign(request: SignedRequest, credentials: Credentials, region: string, service: string, expiresSeconds: number, now: Date): string;
package/dist/sigv4.js ADDED
@@ -0,0 +1,149 @@
1
+ // AWS Signature Version 4, as S3 and every store that speaks it reads it: a request signed in its
2
+ // headers, and a URL signed in its query string (feature 055). The runtime's `SigV4.scala` made the
3
+ // same choices, and the same vectors hold both: paths are encoded once, segment by segment, as S3
4
+ // expects; a put's payload is `UNSIGNED-PAYLOAD`, so a stream is sent once and never hashed.
5
+ //
6
+ // The signer works on a request's parts — its method, its endpoint, its path and query *before*
7
+ // encoding, the headers it signs and the hash of its payload — so the header form and the query form
8
+ // share one canonical request, and AWS's published examples test it with no client at all.
9
+ import { createHash, createHmac } from "node:crypto";
10
+ /** A key pair. Never printed whole: `toString` hides the secret. */
11
+ export class Credentials {
12
+ accessKey;
13
+ #secretKey;
14
+ constructor(accessKey, secretKey) {
15
+ this.accessKey = accessKey;
16
+ this.#secretKey = secretKey;
17
+ Object.freeze(this);
18
+ }
19
+ /** @internal */
20
+ get _secretKey() {
21
+ return this.#secretKey;
22
+ }
23
+ toString() {
24
+ return `Credentials(${this.accessKey}, ***)`;
25
+ }
26
+ toJSON() {
27
+ return this.toString();
28
+ }
29
+ }
30
+ export const ALGORITHM = "AWS4-HMAC-SHA256";
31
+ /** The payload hash a put sends so its body is streamed once and never hashed. */
32
+ export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
33
+ const utf8 = new TextEncoder();
34
+ export function sha256Hex(data) {
35
+ return createHash("sha256").update(typeof data === "string" ? utf8.encode(data) : data).digest("hex");
36
+ }
37
+ export function hmac(key, data) {
38
+ return createHmac("sha256", key).update(data, "utf8").digest();
39
+ }
40
+ /** The hash of an empty payload. */
41
+ export const EMPTY_PAYLOAD = sha256Hex(new Uint8Array());
42
+ /** RFC 3986 encoding as SigV4 states it: unreserved characters kept, everything else `%XX` of its UTF-8 bytes. */
43
+ export function encode(text, encodeSlash) {
44
+ let out = "";
45
+ for (const b of utf8.encode(text)) {
46
+ const c = String.fromCharCode(b);
47
+ if ((b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) || (b >= 0x30 && b <= 0x39) || c === "-" || c === "_" || c === "." || c === "~" || (c === "/" && !encodeSlash)) {
48
+ out += c;
49
+ }
50
+ else {
51
+ out += "%" + b.toString(16).toUpperCase().padStart(2, "0");
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ /** One request: the endpoint's scheme and authority, a decoded path and decoded query pairs. */
57
+ export class SignedRequest {
58
+ method;
59
+ endpoint;
60
+ path;
61
+ query;
62
+ constructor(method, endpoint, path, query = []) {
63
+ this.method = method;
64
+ this.endpoint = typeof endpoint === "string" ? new URL(endpoint) : endpoint;
65
+ this.path = path;
66
+ this.query = query;
67
+ Object.freeze(this);
68
+ }
69
+ /** The `host` header's value: the host, with the port when the endpoint names one. */
70
+ get host() {
71
+ return this.endpoint.port === "" ? this.endpoint.hostname : `${this.endpoint.hostname}:${this.endpoint.port}`;
72
+ }
73
+ get canonicalPath() {
74
+ const raw = this.endpoint.pathname === "/" ? "" : this.endpoint.pathname;
75
+ const base = raw.endsWith("/") ? raw.slice(0, -1) : raw;
76
+ const p = this.path.startsWith("/") ? this.path : "/" + this.path;
77
+ return base + encode(p, false);
78
+ }
79
+ get canonicalQuery() {
80
+ return this.query
81
+ .map(([k, v]) => [encode(k, true), encode(v, true)])
82
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
83
+ .map(([k, v]) => `${k}=${v}`)
84
+ .join("&");
85
+ }
86
+ /** The URL the request is sent to: the same encoding as the one signed. */
87
+ get url() {
88
+ const q = this.canonicalQuery;
89
+ return `${this.endpoint.protocol}//${this.host}${this.canonicalPath}${q === "" ? "" : "?" + q}`;
90
+ }
91
+ withQuery(more) {
92
+ return new SignedRequest(this.method, this.endpoint, this.path, [...this.query, ...more]);
93
+ }
94
+ }
95
+ function pad(n, width = 2) {
96
+ return String(n).padStart(width, "0");
97
+ }
98
+ /** `20150830T123600Z`. */
99
+ export function amzDate(now) {
100
+ return `${shortDate(now)}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`;
101
+ }
102
+ function shortDate(now) {
103
+ return `${pad(now.getUTCFullYear(), 4)}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}`;
104
+ }
105
+ function signingKey(secret, now, region, service) {
106
+ const date = hmac(utf8.encode("AWS4" + secret), shortDate(now));
107
+ return hmac(hmac(hmac(date, region), service), "aws4_request");
108
+ }
109
+ function stringToSign(stamp, scope, canonicalRequest) {
110
+ return `${ALGORITHM}\n${stamp}\n${scope}\n${sha256Hex(canonicalRequest)}`;
111
+ }
112
+ /**
113
+ * The headers to send with `request` so the store believes it: `x-amz-date` and `Authorization`.
114
+ * `headers` are the ones to sign beside `host` and `x-amz-date` — an S3 request includes
115
+ * `x-amz-content-sha256` among them — and are sent as given.
116
+ */
117
+ export function sign(request, headers, payloadHash, credentials, region, service, now) {
118
+ const stamp = amzDate(now);
119
+ const signed = new Map();
120
+ for (const [k, v] of Object.entries(headers))
121
+ signed.set(k.toLowerCase(), v);
122
+ signed.set("host", request.host);
123
+ signed.set("x-amz-date", stamp);
124
+ const sorted = [...signed.entries()].map(([k, v]) => [k, v.trim().replace(/ +/g, " ")]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
125
+ const names = sorted.map(([k]) => k).join(";");
126
+ const canonical = sorted.map(([k, v]) => `${k}:${v}\n`).join("");
127
+ const scope = `${shortDate(now)}/${region}/${service}/aws4_request`;
128
+ const creq = [request.method, request.canonicalPath, request.canonicalQuery, canonical, names, payloadHash].join("\n");
129
+ const signature = hmac(signingKey(credentials._secretKey, now, region, service), stringToSign(stamp, scope, creq)).toString("hex");
130
+ return {
131
+ "x-amz-date": stamp,
132
+ Authorization: `${ALGORITHM} Credential=${credentials.accessKey}/${scope}, SignedHeaders=${names}, Signature=${signature}`,
133
+ };
134
+ }
135
+ /** A URL anyone may use, until it expires, for `request`'s method on its path. Signs `host` alone. */
136
+ export function presign(request, credentials, region, service, expiresSeconds, now) {
137
+ const stamp = amzDate(now);
138
+ const scope = `${shortDate(now)}/${region}/${service}/aws4_request`;
139
+ const withAuth = request.withQuery([
140
+ ["X-Amz-Algorithm", ALGORITHM],
141
+ ["X-Amz-Credential", `${credentials.accessKey}/${scope}`],
142
+ ["X-Amz-Date", stamp],
143
+ ["X-Amz-Expires", String(expiresSeconds)],
144
+ ["X-Amz-SignedHeaders", "host"],
145
+ ]);
146
+ const creq = [request.method, withAuth.canonicalPath, withAuth.canonicalQuery, `host:${request.host}\n`, "host", UNSIGNED_PAYLOAD].join("\n");
147
+ const signature = hmac(signingKey(credentials._secretKey, now, region, service), stringToSign(stamp, scope, creq)).toString("hex");
148
+ return withAuth.withQuery([["X-Amz-Signature", signature]]).url;
149
+ }