@openscout/protocol 0.2.102 → 0.2.104
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/actors.d.ts +2 -2
- package/dist/actors.d.ts.map +1 -1
- package/dist/actors.js +1 -0
- package/dist/broker-routes.d.ts +1 -0
- package/dist/broker-routes.d.ts.map +1 -1
- package/dist/broker-routes.js +1 -0
- package/dist/channel-identity.d.ts +77 -0
- package/dist/channel-identity.d.ts.map +1 -1
- package/dist/channel-identity.js +134 -0
- package/dist/channel-invites.d.ts +179 -0
- package/dist/channel-invites.d.ts.map +1 -0
- package/dist/channel-invites.js +166 -0
- package/dist/channel-reception.d.ts +78 -0
- package/dist/channel-reception.d.ts.map +1 -0
- package/dist/channel-reception.js +219 -0
- package/dist/common.d.ts +1 -1
- package/dist/common.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/machines.d.ts +2 -0
- package/dist/machines.d.ts.map +1 -1
- package/dist/mesh.d.ts +8 -0
- package/dist/mesh.d.ts.map +1 -1
- package/dist/runtime-catalog-contract.d.ts +1 -1
- package/dist/runtime-catalog-contract.d.ts.map +1 -1
- package/dist/runtime-catalog-contract.js +1 -1
- package/dist/runtime-catalog.generated.d.ts +29 -1
- package/dist/runtime-catalog.generated.d.ts.map +1 -1
- package/dist/runtime-catalog.generated.js +34 -1
- package/dist/runtime-execution.d.ts +1 -1
- package/dist/runtime-execution.d.ts.map +1 -1
- package/dist/transports.d.ts +39 -1
- package/dist/transports.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/actors.ts +2 -0
- package/src/broker-routes.ts +1 -0
- package/src/channel-identity.ts +151 -0
- package/src/channel-invites.ts +343 -0
- package/src/channel-reception.ts +308 -0
- package/src/common.ts +1 -0
- package/src/index.ts +2 -0
- package/src/machines.ts +2 -0
- package/src/mesh.ts +8 -0
- package/src/runtime-catalog-contract.ts +1 -1
- package/src/runtime-catalog.generated.ts +34 -1
- package/src/runtime-catalog.v1.json +13 -2
- package/src/transports.ts +48 -0
package/dist/actors.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ActorKind, AdvertiseScope, AgentState, DeliveryTransport, MetadataMap, ScoutId } from "./common.js";
|
|
2
2
|
export type AgentClass = "general" | "builder" | "reviewer" | "researcher" | "operator" | "bridge" | "system";
|
|
3
3
|
export type AgentCapability = "chat" | "invoke" | "deliver" | "speak" | "listen" | "bridge" | "summarize" | "review" | "execute";
|
|
4
|
-
export type AgentEndpointTransport = Extract<DeliveryTransport, "local_socket" | "http" | "websocket" | "pairing_bridge" | "claude_channel" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "pi_rpc" | "grok_acp" | "kimi_acp" | "cursor_acp" | "opencode_acp" | "tmux" | "cursor_exec" | "cursor_cli_text" | "cursor_cli_stream_json" | "cursor_sdk_local">;
|
|
5
|
-
export declare const AGENT_HARNESSES: readonly ["codex", "claude", "grok", "grok-acp", "kimi", "flue", "cursor", "opencode", "native", "worker", "bridge", "http", "pi"];
|
|
4
|
+
export type AgentEndpointTransport = Extract<DeliveryTransport, "local_socket" | "http" | "websocket" | "pairing_bridge" | "claude_channel" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "pi_rpc" | "grok_acp" | "kimi_acp" | "cursor_acp" | "opencode_acp" | "devin_acp" | "tmux" | "cursor_exec" | "cursor_cli_text" | "cursor_cli_stream_json" | "cursor_sdk_local">;
|
|
5
|
+
export declare const AGENT_HARNESSES: readonly ["codex", "claude", "grok", "grok-acp", "kimi", "flue", "cursor", "opencode", "devin", "native", "worker", "bridge", "http", "pi"];
|
|
6
6
|
export type AgentHarness = typeof AGENT_HARNESSES[number];
|
|
7
7
|
export type WakePolicy = "manual" | "on_demand" | "keep_warm";
|
|
8
8
|
export interface ActorIdentity {
|
package/dist/actors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actors.d.ts","sourceRoot":"","sources":["../src/actors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,WAAW,EACX,OAAO,EACR,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,SAAS,GACT,UAAU,GACV,YAAY,GACZ,UAAU,GACV,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,QAAQ,GACR,SAAS,GACT,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,WAAW,GACX,QAAQ,GACR,SAAS,CAAC;AAEd,MAAM,MAAM,sBAAsB,GAAG,OAAO,CAC1C,iBAAiB,EACf,cAAc,GACd,MAAM,GACN,WAAW,GACX,gBAAgB,GAChB,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,YAAY,GACZ,eAAe,GACf,QAAQ,GACR,UAAU,GACV,UAAU,GACV,YAAY,GACZ,cAAc,GACd,MAAM,GACN,aAAa,GACb,iBAAiB,GACjB,wBAAwB,GACxB,kBAAkB,CACrB,CAAC;AAEF,eAAO,MAAM,eAAe,
|
|
1
|
+
{"version":3,"file":"actors.d.ts","sourceRoot":"","sources":["../src/actors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,WAAW,EACX,OAAO,EACR,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,SAAS,GACT,UAAU,GACV,YAAY,GACZ,UAAU,GACV,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,QAAQ,GACR,SAAS,GACT,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,WAAW,GACX,QAAQ,GACR,SAAS,CAAC;AAEd,MAAM,MAAM,sBAAsB,GAAG,OAAO,CAC1C,iBAAiB,EACf,cAAc,GACd,MAAM,GACN,WAAW,GACX,gBAAgB,GAChB,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,YAAY,GACZ,eAAe,GACf,QAAQ,GACR,UAAU,GACV,UAAU,GACV,YAAY,GACZ,cAAc,GACd,WAAW,GACX,MAAM,GACN,aAAa,GACb,iBAAiB,GACjB,wBAAwB,GACxB,kBAAkB,CACrB,CAAC;AAEF,eAAO,MAAM,eAAe,6IAelB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC;AAE1D,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,WAAW,GAAG,WAAW,CAAC;AAE9D,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,SAAS,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAED,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,IAAI,EAAE,OAAO,CAAC;IACd,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,UAAU,CAAC;IACvB,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,UAAU,EAAE,UAAU,CAAC;IACvB,UAAU,EAAE,OAAO,CAAC;IACpB,eAAe,EAAE,OAAO,CAAC;IACzB,cAAc,EAAE,cAAc,CAAC;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,YAAY,CAAC;IACrB,YAAY,EAAE,eAAe,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,YAAY,CAAC;IACtB,SAAS,EAAE,sBAAsB,CAAC;IAClC,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB"}
|
package/dist/actors.js
CHANGED
package/dist/broker-routes.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare const scoutBrokerPaths: {
|
|
|
18
18
|
readonly messages: "/v1/messages";
|
|
19
19
|
readonly brokerMessages: "/v1/broker/messages";
|
|
20
20
|
readonly eventsStream: "/v1/events/stream";
|
|
21
|
+
readonly commands: "/v1/commands";
|
|
21
22
|
readonly actors: "/v1/actors";
|
|
22
23
|
readonly agents: "/v1/agents";
|
|
23
24
|
readonly endpoints: "/v1/endpoints";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broker-routes.d.ts","sourceRoot":"","sources":["../src/broker-routes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB
|
|
1
|
+
{"version":3,"file":"broker-routes.d.ts","sourceRoot":"","sources":["../src/broker-routes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CnB,CAAC;AAEX,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAG3E;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAKvE;AAED,6EAA6E;AAC7E,wBAAgB,gCAAgC,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,yBAAyB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEjE"}
|
package/dist/broker-routes.js
CHANGED
|
@@ -20,6 +20,83 @@ export declare function isOpaqueChannelId(value: string | null | undefined): val
|
|
|
20
20
|
export declare function directChannelNaturalKey(participantIds: ScoutId[]): string;
|
|
21
21
|
export declare function namedChannelNaturalKey(channel: string): string;
|
|
22
22
|
export declare function systemChannelNaturalKey(name: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* The space every channel that predates spaces belongs to.
|
|
25
|
+
*
|
|
26
|
+
* It is not a migration target; it is the name of what already exists. A
|
|
27
|
+
* channel with no space marker *is* a default-space channel, decided at read
|
|
28
|
+
* time by {@link channelSpaceSlug}, so no record is ever rewritten to acquire
|
|
29
|
+
* one.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_CHAT_SPACE_SLUG = "home";
|
|
32
|
+
/** Metadata key carrying a channel's (or a space record's) space slug. */
|
|
33
|
+
export declare const CHANNEL_SPACE_SLUG_METADATA = "spaceSlug";
|
|
34
|
+
/** Natural-key prefix for the conversation record that *is* a space. */
|
|
35
|
+
export declare const SPACE_NATURAL_KEY_PREFIX = "space:";
|
|
36
|
+
export declare function isChatSpaceSlug(value: string | null | undefined): value is string;
|
|
37
|
+
/**
|
|
38
|
+
* Read a slug from untrusted input, or return `null`.
|
|
39
|
+
*
|
|
40
|
+
* Lowercasing and trimming are accepted because they cannot change which space
|
|
41
|
+
* is meant. Anything else -- a space character, a slash, a percent escape --
|
|
42
|
+
* is refused rather than repaired: silently rewriting `work/secret` into
|
|
43
|
+
* `worksecret` would resolve a request to a space the caller did not name.
|
|
44
|
+
*/
|
|
45
|
+
export declare function normalizeChatSpaceSlug(value: string | null | undefined): string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Derive a slug from a human title, for creation only.
|
|
48
|
+
*
|
|
49
|
+
* This one *does* rewrite, because the title is prose the operator typed and
|
|
50
|
+
* the slug is an identifier we are minting from it. It is never used to
|
|
51
|
+
* interpret a slug that arrived on a request.
|
|
52
|
+
*/
|
|
53
|
+
export declare function chatSpaceSlugFromTitle(title: string): string | null;
|
|
54
|
+
/** The natural key of the conversation record that carries a space. */
|
|
55
|
+
export declare function spaceNaturalKey(spaceSlug: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* The natural key of a named channel *inside a space*.
|
|
58
|
+
*
|
|
59
|
+
* The default space returns the byte-identical legacy string, which is what
|
|
60
|
+
* keeps every channel that exists today on the exact id it already has:
|
|
61
|
+
* `stableChannelId` is a pure function of this key, so an unchanged key is an
|
|
62
|
+
* unchanged id, an unchanged feed, and unchanged invitations. Nothing is
|
|
63
|
+
* migrated because nothing moves.
|
|
64
|
+
*
|
|
65
|
+
* `/` is the separator because {@link encodeIdentityPart} percent-encodes it,
|
|
66
|
+
* so a channel whose *name* contains a slash encodes to `%2F` and can never be
|
|
67
|
+
* mistaken for the space boundary. A legacy key and a spaced key are therefore
|
|
68
|
+
* distinguishable by inspection, in both directions.
|
|
69
|
+
*/
|
|
70
|
+
export declare function spacedChannelNaturalKey(spaceSlug: string, channel: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Which space a conversation belongs to.
|
|
73
|
+
*
|
|
74
|
+
* Read in priority order: the explicit marker, then the natural key, then the
|
|
75
|
+
* default. The fallback is not a guess -- a record with no marker was written
|
|
76
|
+
* before spaces existed, and the default space is precisely the set of things
|
|
77
|
+
* that existed before spaces. That is why this can be derived at read time
|
|
78
|
+
* instead of backfilled.
|
|
79
|
+
*/
|
|
80
|
+
export declare function channelSpaceSlug(conversation: ConversationIdentityRecord): string;
|
|
81
|
+
/**
|
|
82
|
+
* The space named inside a natural key, or `null` when the key names none.
|
|
83
|
+
*
|
|
84
|
+
* `null` is distinct from `"home"`: it means this key carries no space at all
|
|
85
|
+
* (a direct message, a system record, a legacy channel), and the caller
|
|
86
|
+
* decides what that means. Only {@link channelSpaceSlug} turns it into the
|
|
87
|
+
* default.
|
|
88
|
+
*/
|
|
89
|
+
export declare function chatSpaceSlugFromNaturalKey(naturalKey: string | null | undefined): string | null;
|
|
90
|
+
/**
|
|
91
|
+
* Whether a conversation record is a space rather than a room.
|
|
92
|
+
*
|
|
93
|
+
* Two independent markers have to agree. `kind: "system"` is what keeps the
|
|
94
|
+
* record out of every conversation list; the natural-key prefix is what says
|
|
95
|
+
* it is a *space* and not some other system record. Requiring both means a
|
|
96
|
+
* future system conversation cannot be mistaken for a space, and a channel
|
|
97
|
+
* cannot be mistaken for one either.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isChatSpaceRecord(conversation: ConversationIdentityRecord): boolean;
|
|
23
100
|
export declare function channelNaturalKeyFromMetadata(metadata: MetadataMap | undefined): string | null;
|
|
24
101
|
/**
|
|
25
102
|
* Read the semantic identity of a conversation, including the structural ids
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-identity.d.ts","sourceRoot":"","sources":["../src/channel-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAExD,MAAM,MAAM,0BAA0B,GAAG;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB,CAAC;AAEF,eAAO,MAAM,iBAAiB,SAAS,CAAC;AACxC,eAAO,MAAM,cAAc,SAAoB,CAAC;AAChD,eAAO,MAAM,qBAAqB,UAAU,CAAC;AAC7C,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C,eAAO,MAAM,4BAA4B,eAAe,CAAC;AAEzD,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,MAAM,GAAG,OAAO,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAW3D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,KAAK,IAAI,OAAO,CAWpF;AAED,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,OAAO,EAAE,GAAG,MAAM,CAEzE;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,WAAW,GAAG,SAAS,GAChC,MAAM,GAAG,IAAI,CAGf;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,0BAA0B,GACvC,MAAM,GAAG,IAAI,CAwBf;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,SAAS,0BAA0B,EAC9E,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,EAC1B,UAAU,EAAE,MAAM,GACjB,CAAC,EAAE,CAeL;AAED,wBAAgB,mCAAmC,CAAC,CAAC,SAAS,0BAA0B,EACtF,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,EAC1B,UAAU,EAAE,MAAM,GACjB,CAAC,GAAG,SAAS,CAEf"}
|
|
1
|
+
{"version":3,"file":"channel-identity.d.ts","sourceRoot":"","sources":["../src/channel-identity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAExD,MAAM,MAAM,0BAA0B,GAAG;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB,CAAC;AAEF,eAAO,MAAM,iBAAiB,SAAS,CAAC;AACxC,eAAO,MAAM,cAAc,SAAoB,CAAC;AAChD,eAAO,MAAM,qBAAqB,UAAU,CAAC;AAC7C,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C,eAAO,MAAM,4BAA4B,eAAe,CAAC;AAEzD,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,MAAM,GAAG,OAAO,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAW3D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,KAAK,IAAI,OAAO,CAWpF;AAED,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,OAAO,EAAE,GAAG,MAAM,CAEzE;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5D;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,SAAS,CAAC;AAE9C,0EAA0E;AAC1E,eAAO,MAAM,2BAA2B,cAAc,CAAC;AAEvD,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,WAAW,CAAC;AAWjD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,KAAK,IAAI,MAAM,CAEjF;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAGtF;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASnE;AAED,uEAAuE;AACvE,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIzD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAMlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,0BAA0B,GAAG,MAAM,CAQjF;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACpC,MAAM,GAAG,IAAI,CAcf;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,0BAA0B,GAAG,OAAO,CAInF;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,WAAW,GAAG,SAAS,GAChC,MAAM,GAAG,IAAI,CAGf;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,0BAA0B,GACvC,MAAM,GAAG,IAAI,CAwBf;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,SAAS,0BAA0B,EAC9E,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,EAC1B,UAAU,EAAE,MAAM,GACjB,CAAC,EAAE,CAeL;AAED,wBAAgB,mCAAmC,CAAC,CAAC,SAAS,0BAA0B,EACtF,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,EAC1B,UAAU,EAAE,MAAM,GACjB,CAAC,GAAG,SAAS,CAEf"}
|
package/dist/channel-identity.js
CHANGED
|
@@ -39,6 +39,140 @@ export function namedChannelNaturalKey(channel) {
|
|
|
39
39
|
export function systemChannelNaturalKey(name) {
|
|
40
40
|
return `system:${encodeIdentityPart(name.trim().toLowerCase() || "system")}`;
|
|
41
41
|
}
|
|
42
|
+
/* -- spaces ---------------------------------------------------------------- */
|
|
43
|
+
/**
|
|
44
|
+
* The space every channel that predates spaces belongs to.
|
|
45
|
+
*
|
|
46
|
+
* It is not a migration target; it is the name of what already exists. A
|
|
47
|
+
* channel with no space marker *is* a default-space channel, decided at read
|
|
48
|
+
* time by {@link channelSpaceSlug}, so no record is ever rewritten to acquire
|
|
49
|
+
* one.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_CHAT_SPACE_SLUG = "home";
|
|
52
|
+
/** Metadata key carrying a channel's (or a space record's) space slug. */
|
|
53
|
+
export const CHANNEL_SPACE_SLUG_METADATA = "spaceSlug";
|
|
54
|
+
/** Natural-key prefix for the conversation record that *is* a space. */
|
|
55
|
+
export const SPACE_NATURAL_KEY_PREFIX = "space:";
|
|
56
|
+
/**
|
|
57
|
+
* Slugs are the durable half of a space's identity, so they are deliberately
|
|
58
|
+
* boring: lowercase, digits and single-width hyphens, never leading or
|
|
59
|
+
* trailing punctuation. The human-readable half is the conversation `title`,
|
|
60
|
+
* which is free-form and renameable; the slug never changes once minted
|
|
61
|
+
* because it is mixed into every channel id in the space.
|
|
62
|
+
*/
|
|
63
|
+
const CHAT_SPACE_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
64
|
+
export function isChatSpaceSlug(value) {
|
|
65
|
+
return typeof value === "string" && CHAT_SPACE_SLUG_PATTERN.test(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read a slug from untrusted input, or return `null`.
|
|
69
|
+
*
|
|
70
|
+
* Lowercasing and trimming are accepted because they cannot change which space
|
|
71
|
+
* is meant. Anything else -- a space character, a slash, a percent escape --
|
|
72
|
+
* is refused rather than repaired: silently rewriting `work/secret` into
|
|
73
|
+
* `worksecret` would resolve a request to a space the caller did not name.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeChatSpaceSlug(value) {
|
|
76
|
+
const candidate = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
77
|
+
return isChatSpaceSlug(candidate) ? candidate : null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Derive a slug from a human title, for creation only.
|
|
81
|
+
*
|
|
82
|
+
* This one *does* rewrite, because the title is prose the operator typed and
|
|
83
|
+
* the slug is an identifier we are minting from it. It is never used to
|
|
84
|
+
* interpret a slug that arrived on a request.
|
|
85
|
+
*/
|
|
86
|
+
export function chatSpaceSlugFromTitle(title) {
|
|
87
|
+
const slug = title
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
91
|
+
.replace(/^-+|-+$/g, "")
|
|
92
|
+
.slice(0, 32)
|
|
93
|
+
.replace(/-+$/g, "");
|
|
94
|
+
return isChatSpaceSlug(slug) ? slug : null;
|
|
95
|
+
}
|
|
96
|
+
/** The natural key of the conversation record that carries a space. */
|
|
97
|
+
export function spaceNaturalKey(spaceSlug) {
|
|
98
|
+
return `${SPACE_NATURAL_KEY_PREFIX}${encodeIdentityPart(spaceSlug.trim().toLowerCase() || DEFAULT_CHAT_SPACE_SLUG)}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The natural key of a named channel *inside a space*.
|
|
102
|
+
*
|
|
103
|
+
* The default space returns the byte-identical legacy string, which is what
|
|
104
|
+
* keeps every channel that exists today on the exact id it already has:
|
|
105
|
+
* `stableChannelId` is a pure function of this key, so an unchanged key is an
|
|
106
|
+
* unchanged id, an unchanged feed, and unchanged invitations. Nothing is
|
|
107
|
+
* migrated because nothing moves.
|
|
108
|
+
*
|
|
109
|
+
* `/` is the separator because {@link encodeIdentityPart} percent-encodes it,
|
|
110
|
+
* so a channel whose *name* contains a slash encodes to `%2F` and can never be
|
|
111
|
+
* mistaken for the space boundary. A legacy key and a spaced key are therefore
|
|
112
|
+
* distinguishable by inspection, in both directions.
|
|
113
|
+
*/
|
|
114
|
+
export function spacedChannelNaturalKey(spaceSlug, channel) {
|
|
115
|
+
const slug = normalizeChatSpaceSlug(spaceSlug) ?? DEFAULT_CHAT_SPACE_SLUG;
|
|
116
|
+
const name = encodeIdentityPart(channel.trim().toLowerCase() || "shared");
|
|
117
|
+
return slug === DEFAULT_CHAT_SPACE_SLUG
|
|
118
|
+
? namedChannelNaturalKey(channel)
|
|
119
|
+
: `channel:${slug}/${name}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Which space a conversation belongs to.
|
|
123
|
+
*
|
|
124
|
+
* Read in priority order: the explicit marker, then the natural key, then the
|
|
125
|
+
* default. The fallback is not a guess -- a record with no marker was written
|
|
126
|
+
* before spaces existed, and the default space is precisely the set of things
|
|
127
|
+
* that existed before spaces. That is why this can be derived at read time
|
|
128
|
+
* instead of backfilled.
|
|
129
|
+
*/
|
|
130
|
+
export function channelSpaceSlug(conversation) {
|
|
131
|
+
const declared = normalizeChatSpaceSlug(conversation.metadata?.[CHANNEL_SPACE_SLUG_METADATA]);
|
|
132
|
+
if (declared)
|
|
133
|
+
return declared;
|
|
134
|
+
const naturalKey = conversationNaturalKey(conversation);
|
|
135
|
+
return chatSpaceSlugFromNaturalKey(naturalKey) ?? DEFAULT_CHAT_SPACE_SLUG;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The space named inside a natural key, or `null` when the key names none.
|
|
139
|
+
*
|
|
140
|
+
* `null` is distinct from `"home"`: it means this key carries no space at all
|
|
141
|
+
* (a direct message, a system record, a legacy channel), and the caller
|
|
142
|
+
* decides what that means. Only {@link channelSpaceSlug} turns it into the
|
|
143
|
+
* default.
|
|
144
|
+
*/
|
|
145
|
+
export function chatSpaceSlugFromNaturalKey(naturalKey) {
|
|
146
|
+
const key = naturalKey?.trim();
|
|
147
|
+
if (!key)
|
|
148
|
+
return null;
|
|
149
|
+
if (key.startsWith(SPACE_NATURAL_KEY_PREFIX)) {
|
|
150
|
+
return normalizeChatSpaceSlug(decodeURIComponent(key.slice(SPACE_NATURAL_KEY_PREFIX.length)));
|
|
151
|
+
}
|
|
152
|
+
if (!key.startsWith("channel:"))
|
|
153
|
+
return null;
|
|
154
|
+
const rest = key.slice("channel:".length);
|
|
155
|
+
const boundary = rest.indexOf("/");
|
|
156
|
+
// No separator is a legacy key, and a legacy key is a default-space key.
|
|
157
|
+
if (boundary < 0)
|
|
158
|
+
return DEFAULT_CHAT_SPACE_SLUG;
|
|
159
|
+
return normalizeChatSpaceSlug(rest.slice(0, boundary));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether a conversation record is a space rather than a room.
|
|
163
|
+
*
|
|
164
|
+
* Two independent markers have to agree. `kind: "system"` is what keeps the
|
|
165
|
+
* record out of every conversation list; the natural-key prefix is what says
|
|
166
|
+
* it is a *space* and not some other system record. Requiring both means a
|
|
167
|
+
* future system conversation cannot be mistaken for a space, and a channel
|
|
168
|
+
* cannot be mistaken for one either.
|
|
169
|
+
*/
|
|
170
|
+
export function isChatSpaceRecord(conversation) {
|
|
171
|
+
if (conversation.kind !== "system")
|
|
172
|
+
return false;
|
|
173
|
+
const naturalKey = channelNaturalKeyFromMetadata(conversation.metadata);
|
|
174
|
+
return Boolean(naturalKey?.startsWith(SPACE_NATURAL_KEY_PREFIX));
|
|
175
|
+
}
|
|
42
176
|
export function channelNaturalKeyFromMetadata(metadata) {
|
|
43
177
|
const value = metadata?.[CHANNEL_NATURAL_KEY_METADATA];
|
|
44
178
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { MetadataMap, ScoutId } from "./common.js";
|
|
2
|
+
/**
|
|
3
|
+
* Channel invitations: a portable, scoped, revocable capability that lets a
|
|
4
|
+
* teammate bring an agent session they are *already running* into a Scout
|
|
5
|
+
* channel.
|
|
6
|
+
*
|
|
7
|
+
* Three boundaries shape this module.
|
|
8
|
+
*
|
|
9
|
+
* 1. **The secret never lands in a record.** Only `tokenHash` is persisted, so
|
|
10
|
+
* a snapshot, a journal line, or a log dump cannot be replayed into channel
|
|
11
|
+
* membership. The raw token exists in the invitation URL and nowhere else.
|
|
12
|
+
* 2. **Membership is not reception.** Redeeming an invitation makes an actor a
|
|
13
|
+
* channel participant. Whether that actor can actually *receive* is a
|
|
14
|
+
* separate, live judgement made from endpoint evidence — see
|
|
15
|
+
* {@link deriveChannelReception}.
|
|
16
|
+
* 3. **Reachability is stated, not assumed.** A loopback route is recorded as
|
|
17
|
+
* `local_only` so a surface can say plainly that the link will not work for
|
|
18
|
+
* a teammate on another machine.
|
|
19
|
+
*
|
|
20
|
+
* Invites live in the owning conversation's metadata under
|
|
21
|
+
* {@link CHANNEL_INVITES_METADATA_KEY}; the broker remains the only writer.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CHANNEL_INVITES_METADATA_KEY = "channelInvites";
|
|
24
|
+
/**
|
|
25
|
+
* The only scope this pilot grants. It permits posting, reading, and replying
|
|
26
|
+
* inside one channel — never project, shell, or filesystem authority.
|
|
27
|
+
*/
|
|
28
|
+
export type ChannelInviteScope = "channel_participation";
|
|
29
|
+
export declare const CHANNEL_INVITE_SCOPE: ChannelInviteScope;
|
|
30
|
+
/** How far the recorded route actually reaches. */
|
|
31
|
+
export type ChannelInviteReachability =
|
|
32
|
+
/** Loopback only: useless to anybody not on the authority machine. */
|
|
33
|
+
"local_only"
|
|
34
|
+
/** A LAN/mDNS doorway name or address on the local network. */
|
|
35
|
+
| "lan"
|
|
36
|
+
/** A tailnet/mesh route that survives leaving the LAN. */
|
|
37
|
+
| "mesh"
|
|
38
|
+
/** No evidence either way; do not claim it works. */
|
|
39
|
+
| "unknown";
|
|
40
|
+
export interface ChannelInviteRoute {
|
|
41
|
+
/** The broker node that owns the channel and must accept the redemption. */
|
|
42
|
+
authorityNodeId: ScoutId;
|
|
43
|
+
/** Host an invited agent should dial. */
|
|
44
|
+
host: string;
|
|
45
|
+
/** Absolute base URL the invitation document points at. */
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
reachability: ChannelInviteReachability;
|
|
48
|
+
/**
|
|
49
|
+
* Why this route may not reach the invitee. Present whenever reachability is
|
|
50
|
+
* anything but `mesh`; surfaces must render it rather than imply the link
|
|
51
|
+
* works everywhere.
|
|
52
|
+
*/
|
|
53
|
+
caveat?: string;
|
|
54
|
+
}
|
|
55
|
+
/** One concrete agent session that accepted one invitation. */
|
|
56
|
+
export interface ChannelInviteRedemption {
|
|
57
|
+
id: ScoutId;
|
|
58
|
+
/** The channel participant this redemption created or confirmed. */
|
|
59
|
+
actorId: ScoutId;
|
|
60
|
+
agentId?: ScoutId;
|
|
61
|
+
/** The pre-existing harness session attached to the channel. */
|
|
62
|
+
sessionId?: string;
|
|
63
|
+
endpointId?: ScoutId;
|
|
64
|
+
nodeId?: ScoutId;
|
|
65
|
+
harness?: string;
|
|
66
|
+
projectRoot?: string;
|
|
67
|
+
displayName?: string;
|
|
68
|
+
redeemedAt: number;
|
|
69
|
+
}
|
|
70
|
+
export interface ChannelInviteInvitee {
|
|
71
|
+
displayName: string;
|
|
72
|
+
handle?: string;
|
|
73
|
+
/** Set when the invitation names a durable person actor up front. */
|
|
74
|
+
actorId?: ScoutId;
|
|
75
|
+
}
|
|
76
|
+
export interface ChannelInviteRecord {
|
|
77
|
+
id: ScoutId;
|
|
78
|
+
channelId: ScoutId;
|
|
79
|
+
scope: ChannelInviteScope;
|
|
80
|
+
/** Lowercase hex digest of the raw token. The raw token is never stored. */
|
|
81
|
+
tokenHash: string;
|
|
82
|
+
/** A short non-secret fragment so humans can tell two invitations apart. */
|
|
83
|
+
tokenHint: string;
|
|
84
|
+
createdAt: number;
|
|
85
|
+
createdByActorId: ScoutId;
|
|
86
|
+
expiresAt: number | null;
|
|
87
|
+
maxRedemptions: number | null;
|
|
88
|
+
revokedAt?: number;
|
|
89
|
+
revokedByActorId?: ScoutId;
|
|
90
|
+
invitee?: ChannelInviteInvitee;
|
|
91
|
+
route: ChannelInviteRoute;
|
|
92
|
+
redemptions: ChannelInviteRedemption[];
|
|
93
|
+
metadata?: MetadataMap;
|
|
94
|
+
}
|
|
95
|
+
export type ChannelInviteState = "active" | "expired" | "revoked" | "exhausted";
|
|
96
|
+
export declare function channelInviteState(invite: ChannelInviteRecord, nowMs: number): ChannelInviteState;
|
|
97
|
+
export type ChannelInviteRejectionReason = "unknown_token" | "expired" | "revoked" | "exhausted" | "scope_mismatch" | "channel_mismatch" | "missing_identity";
|
|
98
|
+
export interface ChannelInviteRejection {
|
|
99
|
+
reason: ChannelInviteRejectionReason;
|
|
100
|
+
message: string;
|
|
101
|
+
}
|
|
102
|
+
export interface ChannelInviteRedemptionRequest {
|
|
103
|
+
actorId: ScoutId;
|
|
104
|
+
agentId?: ScoutId;
|
|
105
|
+
sessionId?: string;
|
|
106
|
+
endpointId?: ScoutId;
|
|
107
|
+
nodeId?: ScoutId;
|
|
108
|
+
harness?: string;
|
|
109
|
+
projectRoot?: string;
|
|
110
|
+
displayName?: string;
|
|
111
|
+
channelId?: ScoutId;
|
|
112
|
+
scope?: string;
|
|
113
|
+
}
|
|
114
|
+
export type ChannelInviteRedemptionOutcome = {
|
|
115
|
+
ok: true;
|
|
116
|
+
/**
|
|
117
|
+
* The redemption already on file for this identity. Present on a retry;
|
|
118
|
+
* the caller must reuse it rather than appending a second row or
|
|
119
|
+
* consuming another slot.
|
|
120
|
+
*/
|
|
121
|
+
existing: ChannelInviteRedemption | null;
|
|
122
|
+
} | {
|
|
123
|
+
ok: false;
|
|
124
|
+
rejection: ChannelInviteRejection;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Decide whether one redemption attempt may proceed.
|
|
128
|
+
*
|
|
129
|
+
* Retries are first-class: an identity that already redeemed this invitation
|
|
130
|
+
* is accepted again with its original redemption, even once the invitation is
|
|
131
|
+
* exhausted. Re-running an agent's join command must never mint a second
|
|
132
|
+
* identity, burn a second slot, or replace a live session.
|
|
133
|
+
*/
|
|
134
|
+
export declare function evaluateChannelInviteRedemption(input: {
|
|
135
|
+
invite: ChannelInviteRecord;
|
|
136
|
+
request: ChannelInviteRedemptionRequest;
|
|
137
|
+
nowMs: number;
|
|
138
|
+
}): ChannelInviteRedemptionOutcome;
|
|
139
|
+
/**
|
|
140
|
+
* The redemption already recorded for this identity, if any.
|
|
141
|
+
*
|
|
142
|
+
* **Session identity is decisive.** When a request carries a session id, only a
|
|
143
|
+
* redemption for that exact session counts as the same joiner. A durable agent
|
|
144
|
+
* running two concrete sessions is two distinct attachments: this feature
|
|
145
|
+
* exists to attach *the exact session* a teammate is already working in, so
|
|
146
|
+
* letting a second session inherit the first one's membership through a shared
|
|
147
|
+
* agent id would hand it the wrong context and silently skip a use of the
|
|
148
|
+
* invitation.
|
|
149
|
+
*
|
|
150
|
+
* Only when no session is carried does the durable actor or agent id decide.
|
|
151
|
+
*/
|
|
152
|
+
export declare function findChannelInviteRedemption(invite: ChannelInviteRecord, request: Pick<ChannelInviteRedemptionRequest, "actorId" | "sessionId" | "agentId">): ChannelInviteRedemption | null;
|
|
153
|
+
export declare function unknownChannelInviteRejection(): ChannelInviteRejection;
|
|
154
|
+
/** Read the invitations stored on a conversation record. */
|
|
155
|
+
export declare function readChannelInvites(metadata: MetadataMap | undefined): ChannelInviteRecord[];
|
|
156
|
+
/** Match a presented token against stored invitations by its digest. */
|
|
157
|
+
export declare function findChannelInviteByTokenHash(invites: ChannelInviteRecord[], tokenHash: string): ChannelInviteRecord | null;
|
|
158
|
+
/**
|
|
159
|
+
* The public view of an invitation: everything a human or agent needs to
|
|
160
|
+
* understand the offer, and nothing that could be replayed into membership.
|
|
161
|
+
*/
|
|
162
|
+
export interface ChannelInvitePublicView {
|
|
163
|
+
id: ScoutId;
|
|
164
|
+
channelId: ScoutId;
|
|
165
|
+
/** Public authorship, used to show who may revoke this invitation. */
|
|
166
|
+
createdByActorId: ScoutId;
|
|
167
|
+
scope: ChannelInviteScope;
|
|
168
|
+
state: ChannelInviteState;
|
|
169
|
+
tokenHint: string;
|
|
170
|
+
createdAt: number;
|
|
171
|
+
expiresAt: number | null;
|
|
172
|
+
maxRedemptions: number | null;
|
|
173
|
+
redemptionCount: number;
|
|
174
|
+
invitee?: ChannelInviteInvitee;
|
|
175
|
+
route: ChannelInviteRoute;
|
|
176
|
+
redemptions: ChannelInviteRedemption[];
|
|
177
|
+
}
|
|
178
|
+
export declare function channelInvitePublicView(invite: ChannelInviteRecord, nowMs: number): ChannelInvitePublicView;
|
|
179
|
+
//# sourceMappingURL=channel-invites.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel-invites.d.ts","sourceRoot":"","sources":["../src/channel-invites.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,eAAO,MAAM,4BAA4B,mBAAmB,CAAC;AAE7D;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG,uBAAuB,CAAC;AAEzD,eAAO,MAAM,oBAAoB,EAAE,kBAA4C,CAAC;AAEhF,mDAAmD;AACnD,MAAM,MAAM,yBAAyB;AACnC,sEAAsE;AACpE,YAAY;AACd,+DAA+D;GAC7D,KAAK;AACP,0DAA0D;GACxD,MAAM;AACR,qDAAqD;GACnD,SAAS,CAAC;AAEd,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,eAAe,EAAE,OAAO,CAAC;IACzB,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,yBAAyB,CAAC;IACxC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,+DAA+D;AAC/D,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,oEAAoE;IACpE,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,WAAW,EAAE,uBAAuB,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAED,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;AAEhF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,KAAK,EAAE,MAAM,GACZ,kBAAkB,CAUpB;AAED,MAAM,MAAM,4BAA4B,GACpC,eAAe,GACf,SAAS,GACT,SAAS,GACT,WAAW,GACX,gBAAgB,GAChB,kBAAkB,GAClB,kBAAkB,CAAC;AAEvB,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,4BAA4B,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,8BAA8B,GACtC;IACE,EAAE,EAAE,IAAI,CAAC;IACT;;;;OAIG;IACH,QAAQ,EAAE,uBAAuB,GAAG,IAAI,CAAC;CAC1C,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,sBAAsB,CAAA;CAAE,CAAC;AAErD;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE;IACrD,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,8BAA8B,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,8BAA8B,CAkDjC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,IAAI,CAAC,8BAA8B,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC,GACjF,uBAAuB,GAAG,IAAI,CAchC;AAkBD,wBAAgB,6BAA6B,IAAI,sBAAsB,CAItE;AAED,4DAA4D;AAC5D,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,WAAW,GAAG,SAAS,GAChC,mBAAmB,EAAE,CASvB;AAED,wEAAwE;AACxE,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,mBAAmB,EAAE,EAC9B,SAAS,EAAE,MAAM,GAChB,mBAAmB,GAAG,IAAI,CAI5B;AAED;;;GAGG;AACH,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,WAAW,EAAE,uBAAuB,EAAE,CAAC;CACxC;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,mBAAmB,EAC3B,KAAK,EAAE,MAAM,GACZ,uBAAuB,CAgBzB"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel invitations: a portable, scoped, revocable capability that lets a
|
|
3
|
+
* teammate bring an agent session they are *already running* into a Scout
|
|
4
|
+
* channel.
|
|
5
|
+
*
|
|
6
|
+
* Three boundaries shape this module.
|
|
7
|
+
*
|
|
8
|
+
* 1. **The secret never lands in a record.** Only `tokenHash` is persisted, so
|
|
9
|
+
* a snapshot, a journal line, or a log dump cannot be replayed into channel
|
|
10
|
+
* membership. The raw token exists in the invitation URL and nowhere else.
|
|
11
|
+
* 2. **Membership is not reception.** Redeeming an invitation makes an actor a
|
|
12
|
+
* channel participant. Whether that actor can actually *receive* is a
|
|
13
|
+
* separate, live judgement made from endpoint evidence — see
|
|
14
|
+
* {@link deriveChannelReception}.
|
|
15
|
+
* 3. **Reachability is stated, not assumed.** A loopback route is recorded as
|
|
16
|
+
* `local_only` so a surface can say plainly that the link will not work for
|
|
17
|
+
* a teammate on another machine.
|
|
18
|
+
*
|
|
19
|
+
* Invites live in the owning conversation's metadata under
|
|
20
|
+
* {@link CHANNEL_INVITES_METADATA_KEY}; the broker remains the only writer.
|
|
21
|
+
*/
|
|
22
|
+
export const CHANNEL_INVITES_METADATA_KEY = "channelInvites";
|
|
23
|
+
export const CHANNEL_INVITE_SCOPE = "channel_participation";
|
|
24
|
+
export function channelInviteState(invite, nowMs) {
|
|
25
|
+
if (invite.revokedAt !== undefined)
|
|
26
|
+
return "revoked";
|
|
27
|
+
if (invite.expiresAt !== null && invite.expiresAt <= nowMs)
|
|
28
|
+
return "expired";
|
|
29
|
+
if (invite.maxRedemptions !== null
|
|
30
|
+
&& invite.redemptions.length >= invite.maxRedemptions) {
|
|
31
|
+
return "exhausted";
|
|
32
|
+
}
|
|
33
|
+
return "active";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether one redemption attempt may proceed.
|
|
37
|
+
*
|
|
38
|
+
* Retries are first-class: an identity that already redeemed this invitation
|
|
39
|
+
* is accepted again with its original redemption, even once the invitation is
|
|
40
|
+
* exhausted. Re-running an agent's join command must never mint a second
|
|
41
|
+
* identity, burn a second slot, or replace a live session.
|
|
42
|
+
*/
|
|
43
|
+
export function evaluateChannelInviteRedemption(input) {
|
|
44
|
+
const { invite, request, nowMs } = input;
|
|
45
|
+
const actorId = request.actorId?.trim();
|
|
46
|
+
if (!actorId) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
rejection: {
|
|
50
|
+
reason: "missing_identity",
|
|
51
|
+
message: "Redemption needs the actor id of the agent session that is joining.",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (request.channelId?.trim() && request.channelId.trim() !== invite.channelId) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
rejection: {
|
|
59
|
+
reason: "channel_mismatch",
|
|
60
|
+
message: "This invitation does not belong to the requested channel.",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (request.scope?.trim() && request.scope.trim() !== invite.scope) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
rejection: {
|
|
68
|
+
reason: "scope_mismatch",
|
|
69
|
+
message: `This invitation grants ${invite.scope} only.`,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const existing = findChannelInviteRedemption(invite, request);
|
|
74
|
+
if (existing) {
|
|
75
|
+
// Revocation still bites: a revoked invitation stops being a usable
|
|
76
|
+
// capability even for an identity that once held it.
|
|
77
|
+
if (invite.revokedAt !== undefined) {
|
|
78
|
+
return { ok: false, rejection: channelInviteRejection("revoked") };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, existing };
|
|
81
|
+
}
|
|
82
|
+
const state = channelInviteState(invite, nowMs);
|
|
83
|
+
if (state !== "active") {
|
|
84
|
+
return { ok: false, rejection: channelInviteRejection(state) };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, existing: null };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The redemption already recorded for this identity, if any.
|
|
90
|
+
*
|
|
91
|
+
* **Session identity is decisive.** When a request carries a session id, only a
|
|
92
|
+
* redemption for that exact session counts as the same joiner. A durable agent
|
|
93
|
+
* running two concrete sessions is two distinct attachments: this feature
|
|
94
|
+
* exists to attach *the exact session* a teammate is already working in, so
|
|
95
|
+
* letting a second session inherit the first one's membership through a shared
|
|
96
|
+
* agent id would hand it the wrong context and silently skip a use of the
|
|
97
|
+
* invitation.
|
|
98
|
+
*
|
|
99
|
+
* Only when no session is carried does the durable actor or agent id decide.
|
|
100
|
+
*/
|
|
101
|
+
export function findChannelInviteRedemption(invite, request) {
|
|
102
|
+
const sessionId = request.sessionId?.trim();
|
|
103
|
+
if (sessionId) {
|
|
104
|
+
return invite.redemptions.find((redemption) => redemption.sessionId?.trim() === sessionId) ?? null;
|
|
105
|
+
}
|
|
106
|
+
const actorId = request.actorId?.trim();
|
|
107
|
+
const agentId = request.agentId?.trim();
|
|
108
|
+
return invite.redemptions.find((redemption) => {
|
|
109
|
+
if (actorId && redemption.actorId === actorId)
|
|
110
|
+
return true;
|
|
111
|
+
return Boolean(agentId) && redemption.agentId?.trim() === agentId;
|
|
112
|
+
}) ?? null;
|
|
113
|
+
}
|
|
114
|
+
function channelInviteRejection(state) {
|
|
115
|
+
switch (state) {
|
|
116
|
+
case "expired":
|
|
117
|
+
return { reason: "expired", message: "This invitation has expired. Ask for a new one." };
|
|
118
|
+
case "revoked":
|
|
119
|
+
return { reason: "revoked", message: "This invitation was revoked." };
|
|
120
|
+
case "exhausted":
|
|
121
|
+
return {
|
|
122
|
+
reason: "exhausted",
|
|
123
|
+
message: "This invitation has already been used the maximum number of times.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function unknownChannelInviteRejection() {
|
|
128
|
+
// Deliberately identical for a malformed, unknown, and deleted token: a
|
|
129
|
+
// probe must not learn which invitations exist.
|
|
130
|
+
return { reason: "unknown_token", message: "This invitation link is not valid." };
|
|
131
|
+
}
|
|
132
|
+
/** Read the invitations stored on a conversation record. */
|
|
133
|
+
export function readChannelInvites(metadata) {
|
|
134
|
+
const raw = metadata?.[CHANNEL_INVITES_METADATA_KEY];
|
|
135
|
+
if (!Array.isArray(raw))
|
|
136
|
+
return [];
|
|
137
|
+
return raw.filter((entry) => Boolean(entry)
|
|
138
|
+
&& typeof entry === "object"
|
|
139
|
+
&& typeof entry.id === "string"
|
|
140
|
+
&& typeof entry.tokenHash === "string"
|
|
141
|
+
&& Array.isArray(entry.redemptions));
|
|
142
|
+
}
|
|
143
|
+
/** Match a presented token against stored invitations by its digest. */
|
|
144
|
+
export function findChannelInviteByTokenHash(invites, tokenHash) {
|
|
145
|
+
const normalized = tokenHash.trim().toLowerCase();
|
|
146
|
+
if (!normalized)
|
|
147
|
+
return null;
|
|
148
|
+
return invites.find((invite) => invite.tokenHash.toLowerCase() === normalized) ?? null;
|
|
149
|
+
}
|
|
150
|
+
export function channelInvitePublicView(invite, nowMs) {
|
|
151
|
+
return {
|
|
152
|
+
id: invite.id,
|
|
153
|
+
channelId: invite.channelId,
|
|
154
|
+
createdByActorId: invite.createdByActorId,
|
|
155
|
+
scope: invite.scope,
|
|
156
|
+
state: channelInviteState(invite, nowMs),
|
|
157
|
+
tokenHint: invite.tokenHint,
|
|
158
|
+
createdAt: invite.createdAt,
|
|
159
|
+
expiresAt: invite.expiresAt,
|
|
160
|
+
maxRedemptions: invite.maxRedemptions,
|
|
161
|
+
redemptionCount: invite.redemptions.length,
|
|
162
|
+
...(invite.invitee ? { invitee: invite.invitee } : {}),
|
|
163
|
+
route: invite.route,
|
|
164
|
+
redemptions: invite.redemptions,
|
|
165
|
+
};
|
|
166
|
+
}
|