@openscout/protocol 0.2.102 → 0.2.105

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 (48) hide show
  1. package/dist/actors.d.ts +2 -2
  2. package/dist/actors.d.ts.map +1 -1
  3. package/dist/actors.js +1 -0
  4. package/dist/broker-routes.d.ts +1 -0
  5. package/dist/broker-routes.d.ts.map +1 -1
  6. package/dist/broker-routes.js +1 -0
  7. package/dist/channel-identity.d.ts +77 -0
  8. package/dist/channel-identity.d.ts.map +1 -1
  9. package/dist/channel-identity.js +134 -0
  10. package/dist/channel-invites.d.ts +179 -0
  11. package/dist/channel-invites.d.ts.map +1 -0
  12. package/dist/channel-invites.js +166 -0
  13. package/dist/channel-reception.d.ts +78 -0
  14. package/dist/channel-reception.d.ts.map +1 -0
  15. package/dist/channel-reception.js +219 -0
  16. package/dist/common.d.ts +1 -1
  17. package/dist/common.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/machines.d.ts +2 -0
  22. package/dist/machines.d.ts.map +1 -1
  23. package/dist/mesh.d.ts +8 -0
  24. package/dist/mesh.d.ts.map +1 -1
  25. package/dist/runtime-catalog-contract.d.ts +1 -1
  26. package/dist/runtime-catalog-contract.d.ts.map +1 -1
  27. package/dist/runtime-catalog-contract.js +1 -1
  28. package/dist/runtime-catalog.generated.d.ts +29 -1
  29. package/dist/runtime-catalog.generated.d.ts.map +1 -1
  30. package/dist/runtime-catalog.generated.js +34 -1
  31. package/dist/runtime-execution.d.ts +1 -1
  32. package/dist/runtime-execution.d.ts.map +1 -1
  33. package/dist/transports.d.ts +39 -1
  34. package/dist/transports.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/actors.ts +2 -0
  37. package/src/broker-routes.ts +1 -0
  38. package/src/channel-identity.ts +151 -0
  39. package/src/channel-invites.ts +343 -0
  40. package/src/channel-reception.ts +308 -0
  41. package/src/common.ts +1 -0
  42. package/src/index.ts +2 -0
  43. package/src/machines.ts +2 -0
  44. package/src/mesh.ts +8 -0
  45. package/src/runtime-catalog-contract.ts +1 -1
  46. package/src/runtime-catalog.generated.ts +34 -1
  47. package/src/runtime-catalog.v1.json +13 -2
  48. package/src/transports.ts +48 -0
@@ -5,6 +5,7 @@ import type { InvocationRequest } from "./invocations.js";
5
5
  import type { NodeDefinition } from "./mesh.js";
6
6
  import type { MessageRecord } from "./messages.js";
7
7
  import type { CollaborationEvent, CollaborationRecord } from "./collaboration.js";
8
+ import type { ChannelInviteRedemptionRequest, ChannelInviteRoute, ChannelInviteInvitee } from "./channel-invites.js";
8
9
  export interface SubscriptionRequest {
9
10
  actorId: ScoutId;
10
11
  conversationIds?: ScoutId[];
@@ -63,5 +64,42 @@ export interface CollaborationEventAppendCommand {
63
64
  kind: "collaboration.event.append";
64
65
  event: CollaborationEvent;
65
66
  }
66
- export type ControlCommand = NodeUpsertCommand | ActorUpsertCommand | AgentUpsertCommand | AgentEndpointUpsertCommand | ConversationUpsertCommand | BindingUpsertCommand | CollaborationUpsertCommand | CollaborationEventAppendCommand | PostMessageCommand | InvokeAgentCommand | EnsureAwakeCommand | SubscribeCommand;
67
+ /**
68
+ * Invitation writes are broker commands rather than conversation upserts so
69
+ * the read-modify-write of a channel's invitation set happens inside the
70
+ * broker's serialized command queue. Two operators minting an invitation at
71
+ * the same instant must not clobber each other's record.
72
+ */
73
+ export interface ChannelInviteCreateCommand {
74
+ kind: "channel.invite.create";
75
+ channelId: ScoutId;
76
+ inviteId: ScoutId;
77
+ /** Digest only. The raw token never crosses this boundary. */
78
+ tokenHash: string;
79
+ tokenHint: string;
80
+ createdByActorId: ScoutId;
81
+ createdAt: number;
82
+ expiresAt: number | null;
83
+ maxRedemptions: number | null;
84
+ route: ChannelInviteRoute;
85
+ invitee?: ChannelInviteInvitee;
86
+ metadata?: MetadataMap;
87
+ }
88
+ export interface ChannelInviteRevokeCommand {
89
+ kind: "channel.invite.revoke";
90
+ channelId: ScoutId;
91
+ inviteId: ScoutId;
92
+ revokedByActorId: ScoutId;
93
+ revokedAt: number;
94
+ }
95
+ export interface ChannelInviteRedeemCommand {
96
+ kind: "channel.invite.redeem";
97
+ channelId: ScoutId;
98
+ /** Digest of the presented token; the broker matches, never the caller. */
99
+ tokenHash: string;
100
+ redemptionId: ScoutId;
101
+ redeemedAt: number;
102
+ request: ChannelInviteRedemptionRequest;
103
+ }
104
+ export type ControlCommand = NodeUpsertCommand | ActorUpsertCommand | AgentUpsertCommand | AgentEndpointUpsertCommand | ConversationUpsertCommand | BindingUpsertCommand | CollaborationUpsertCommand | CollaborationEventAppendCommand | ChannelInviteCreateCommand | ChannelInviteRevokeCommand | ChannelInviteRedeemCommand | PostMessageCommand | InvokeAgentCommand | EnsureAwakeCommand | SubscribeCommand;
67
105
  //# sourceMappingURL=transports.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transports.d.ts","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3E,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AACtF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAElF,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,EAAE,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,mBAAmB,CAAC;IAC1B,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,UAAU,EAAE,iBAAiB,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,oBAAoB,CAAC;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,kBAAkB,CAAC;IACzB,YAAY,EAAE,mBAAmB,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC,iBAAiB,EAAE,cAAc,GAAG,WAAW,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,cAAc,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,EAAE,aAAa,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,EAAE,eAAe,CAAC;CACxB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,uBAAuB,CAAC;IAC9B,QAAQ,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,YAAY,EAAE,sBAAsB,CAAC;CACtC;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,gBAAgB,CAAC;IACvB,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,sBAAsB,CAAC;IAC7B,MAAM,EAAE,mBAAmB,CAAC;CAC7B;AAED,MAAM,WAAW,+BAA+B;IAC9C,IAAI,EAAE,4BAA4B,CAAC;IACnC,KAAK,EAAE,kBAAkB,CAAC;CAC3B;AAED,MAAM,MAAM,cAAc,GACtB,iBAAiB,GACjB,kBAAkB,GAClB,kBAAkB,GAClB,0BAA0B,GAC1B,yBAAyB,GACzB,oBAAoB,GACpB,0BAA0B,GAC1B,+BAA+B,GAC/B,kBAAkB,GAClB,kBAAkB,GAClB,kBAAkB,GAClB,gBAAgB,CAAC"}
1
+ {"version":3,"file":"transports.d.ts","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openscout/protocol",
3
- "version": "0.2.102",
3
+ "version": "0.2.105",
4
4
  "description": "Typed protocol for the OpenScout local control plane",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -57,5 +57,5 @@
57
57
  "devDependencies": {
58
58
  "typescript": "^5"
59
59
  },
60
- "gitHead": "c50900700bff66c1b1947d3413e54a7b1279d581"
60
+ "gitHead": "4f6679bb04ff580a99a4124d1875ce30cb3b68bd"
61
61
  }
package/src/actors.ts CHANGED
@@ -43,6 +43,7 @@ export type AgentEndpointTransport = Extract<
43
43
  | "kimi_acp"
44
44
  | "cursor_acp"
45
45
  | "opencode_acp"
46
+ | "devin_acp"
46
47
  | "tmux"
47
48
  | "cursor_exec"
48
49
  | "cursor_cli_text"
@@ -59,6 +60,7 @@ export const AGENT_HARNESSES = [
59
60
  "flue",
60
61
  "cursor",
61
62
  "opencode",
63
+ "devin",
62
64
  "native",
63
65
  "worker",
64
66
  "bridge",
@@ -25,6 +25,7 @@ export const scoutBrokerPaths = {
25
25
  messages: "/v1/messages",
26
26
  brokerMessages: "/v1/broker/messages",
27
27
  eventsStream: "/v1/events/stream",
28
+ commands: "/v1/commands",
28
29
  actors: "/v1/actors",
29
30
  agents: "/v1/agents",
30
31
  endpoints: "/v1/endpoints",
@@ -59,6 +59,157 @@ export function systemChannelNaturalKey(name: string): string {
59
59
  return `system:${encodeIdentityPart(name.trim().toLowerCase() || "system")}`;
60
60
  }
61
61
 
62
+ /* -- spaces ---------------------------------------------------------------- */
63
+
64
+ /**
65
+ * The space every channel that predates spaces belongs to.
66
+ *
67
+ * It is not a migration target; it is the name of what already exists. A
68
+ * channel with no space marker *is* a default-space channel, decided at read
69
+ * time by {@link channelSpaceSlug}, so no record is ever rewritten to acquire
70
+ * one.
71
+ */
72
+ export const DEFAULT_CHAT_SPACE_SLUG = "home";
73
+
74
+ /** Metadata key carrying a channel's (or a space record's) space slug. */
75
+ export const CHANNEL_SPACE_SLUG_METADATA = "spaceSlug";
76
+
77
+ /** Natural-key prefix for the conversation record that *is* a space. */
78
+ export const SPACE_NATURAL_KEY_PREFIX = "space:";
79
+
80
+ /**
81
+ * Slugs are the durable half of a space's identity, so they are deliberately
82
+ * boring: lowercase, digits and single-width hyphens, never leading or
83
+ * trailing punctuation. The human-readable half is the conversation `title`,
84
+ * which is free-form and renameable; the slug never changes once minted
85
+ * because it is mixed into every channel id in the space.
86
+ */
87
+ const CHAT_SPACE_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
88
+
89
+ export function isChatSpaceSlug(value: string | null | undefined): value is string {
90
+ return typeof value === "string" && CHAT_SPACE_SLUG_PATTERN.test(value);
91
+ }
92
+
93
+ /**
94
+ * Read a slug from untrusted input, or return `null`.
95
+ *
96
+ * Lowercasing and trimming are accepted because they cannot change which space
97
+ * is meant. Anything else -- a space character, a slash, a percent escape --
98
+ * is refused rather than repaired: silently rewriting `work/secret` into
99
+ * `worksecret` would resolve a request to a space the caller did not name.
100
+ */
101
+ export function normalizeChatSpaceSlug(value: string | null | undefined): string | null {
102
+ const candidate = typeof value === "string" ? value.trim().toLowerCase() : "";
103
+ return isChatSpaceSlug(candidate) ? candidate : null;
104
+ }
105
+
106
+ /**
107
+ * Derive a slug from a human title, for creation only.
108
+ *
109
+ * This one *does* rewrite, because the title is prose the operator typed and
110
+ * the slug is an identifier we are minting from it. It is never used to
111
+ * interpret a slug that arrived on a request.
112
+ */
113
+ export function chatSpaceSlugFromTitle(title: string): string | null {
114
+ const slug = title
115
+ .trim()
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, "-")
118
+ .replace(/^-+|-+$/g, "")
119
+ .slice(0, 32)
120
+ .replace(/-+$/g, "");
121
+ return isChatSpaceSlug(slug) ? slug : null;
122
+ }
123
+
124
+ /** The natural key of the conversation record that carries a space. */
125
+ export function spaceNaturalKey(spaceSlug: string): string {
126
+ return `${SPACE_NATURAL_KEY_PREFIX}${encodeIdentityPart(
127
+ spaceSlug.trim().toLowerCase() || DEFAULT_CHAT_SPACE_SLUG,
128
+ )}`;
129
+ }
130
+
131
+ /**
132
+ * The natural key of a named channel *inside a space*.
133
+ *
134
+ * The default space returns the byte-identical legacy string, which is what
135
+ * keeps every channel that exists today on the exact id it already has:
136
+ * `stableChannelId` is a pure function of this key, so an unchanged key is an
137
+ * unchanged id, an unchanged feed, and unchanged invitations. Nothing is
138
+ * migrated because nothing moves.
139
+ *
140
+ * `/` is the separator because {@link encodeIdentityPart} percent-encodes it,
141
+ * so a channel whose *name* contains a slash encodes to `%2F` and can never be
142
+ * mistaken for the space boundary. A legacy key and a spaced key are therefore
143
+ * distinguishable by inspection, in both directions.
144
+ */
145
+ export function spacedChannelNaturalKey(spaceSlug: string, channel: string): string {
146
+ const slug = normalizeChatSpaceSlug(spaceSlug) ?? DEFAULT_CHAT_SPACE_SLUG;
147
+ const name = encodeIdentityPart(channel.trim().toLowerCase() || "shared");
148
+ return slug === DEFAULT_CHAT_SPACE_SLUG
149
+ ? namedChannelNaturalKey(channel)
150
+ : `channel:${slug}/${name}`;
151
+ }
152
+
153
+ /**
154
+ * Which space a conversation belongs to.
155
+ *
156
+ * Read in priority order: the explicit marker, then the natural key, then the
157
+ * default. The fallback is not a guess -- a record with no marker was written
158
+ * before spaces existed, and the default space is precisely the set of things
159
+ * that existed before spaces. That is why this can be derived at read time
160
+ * instead of backfilled.
161
+ */
162
+ export function channelSpaceSlug(conversation: ConversationIdentityRecord): string {
163
+ const declared = normalizeChatSpaceSlug(
164
+ conversation.metadata?.[CHANNEL_SPACE_SLUG_METADATA] as string | undefined,
165
+ );
166
+ if (declared) return declared;
167
+
168
+ const naturalKey = conversationNaturalKey(conversation);
169
+ return chatSpaceSlugFromNaturalKey(naturalKey) ?? DEFAULT_CHAT_SPACE_SLUG;
170
+ }
171
+
172
+ /**
173
+ * The space named inside a natural key, or `null` when the key names none.
174
+ *
175
+ * `null` is distinct from `"home"`: it means this key carries no space at all
176
+ * (a direct message, a system record, a legacy channel), and the caller
177
+ * decides what that means. Only {@link channelSpaceSlug} turns it into the
178
+ * default.
179
+ */
180
+ export function chatSpaceSlugFromNaturalKey(
181
+ naturalKey: string | null | undefined,
182
+ ): string | null {
183
+ const key = naturalKey?.trim();
184
+ if (!key) return null;
185
+ if (key.startsWith(SPACE_NATURAL_KEY_PREFIX)) {
186
+ return normalizeChatSpaceSlug(
187
+ decodeURIComponent(key.slice(SPACE_NATURAL_KEY_PREFIX.length)),
188
+ );
189
+ }
190
+ if (!key.startsWith("channel:")) return null;
191
+ const rest = key.slice("channel:".length);
192
+ const boundary = rest.indexOf("/");
193
+ // No separator is a legacy key, and a legacy key is a default-space key.
194
+ if (boundary < 0) return DEFAULT_CHAT_SPACE_SLUG;
195
+ return normalizeChatSpaceSlug(rest.slice(0, boundary));
196
+ }
197
+
198
+ /**
199
+ * Whether a conversation record is a space rather than a room.
200
+ *
201
+ * Two independent markers have to agree. `kind: "system"` is what keeps the
202
+ * record out of every conversation list; the natural-key prefix is what says
203
+ * it is a *space* and not some other system record. Requiring both means a
204
+ * future system conversation cannot be mistaken for a space, and a channel
205
+ * cannot be mistaken for one either.
206
+ */
207
+ export function isChatSpaceRecord(conversation: ConversationIdentityRecord): boolean {
208
+ if (conversation.kind !== "system") return false;
209
+ const naturalKey = channelNaturalKeyFromMetadata(conversation.metadata);
210
+ return Boolean(naturalKey?.startsWith(SPACE_NATURAL_KEY_PREFIX));
211
+ }
212
+
62
213
  export function channelNaturalKeyFromMetadata(
63
214
  metadata: MetadataMap | undefined,
64
215
  ): string | null {
@@ -0,0 +1,343 @@
1
+ import type { MetadataMap, ScoutId } from "./common.js";
2
+
3
+ /**
4
+ * Channel invitations: a portable, scoped, revocable capability that lets a
5
+ * teammate bring an agent session they are *already running* into a Scout
6
+ * channel.
7
+ *
8
+ * Three boundaries shape this module.
9
+ *
10
+ * 1. **The secret never lands in a record.** Only `tokenHash` is persisted, so
11
+ * a snapshot, a journal line, or a log dump cannot be replayed into channel
12
+ * membership. The raw token exists in the invitation URL and nowhere else.
13
+ * 2. **Membership is not reception.** Redeeming an invitation makes an actor a
14
+ * channel participant. Whether that actor can actually *receive* is a
15
+ * separate, live judgement made from endpoint evidence — see
16
+ * {@link deriveChannelReception}.
17
+ * 3. **Reachability is stated, not assumed.** A loopback route is recorded as
18
+ * `local_only` so a surface can say plainly that the link will not work for
19
+ * a teammate on another machine.
20
+ *
21
+ * Invites live in the owning conversation's metadata under
22
+ * {@link CHANNEL_INVITES_METADATA_KEY}; the broker remains the only writer.
23
+ */
24
+
25
+ export const CHANNEL_INVITES_METADATA_KEY = "channelInvites";
26
+
27
+ /**
28
+ * The only scope this pilot grants. It permits posting, reading, and replying
29
+ * inside one channel — never project, shell, or filesystem authority.
30
+ */
31
+ export type ChannelInviteScope = "channel_participation";
32
+
33
+ export const CHANNEL_INVITE_SCOPE: ChannelInviteScope = "channel_participation";
34
+
35
+ /** How far the recorded route actually reaches. */
36
+ export type ChannelInviteReachability =
37
+ /** Loopback only: useless to anybody not on the authority machine. */
38
+ | "local_only"
39
+ /** A LAN/mDNS doorway name or address on the local network. */
40
+ | "lan"
41
+ /** A tailnet/mesh route that survives leaving the LAN. */
42
+ | "mesh"
43
+ /** No evidence either way; do not claim it works. */
44
+ | "unknown";
45
+
46
+ export interface ChannelInviteRoute {
47
+ /** The broker node that owns the channel and must accept the redemption. */
48
+ authorityNodeId: ScoutId;
49
+ /** Host an invited agent should dial. */
50
+ host: string;
51
+ /** Absolute base URL the invitation document points at. */
52
+ baseUrl: string;
53
+ reachability: ChannelInviteReachability;
54
+ /**
55
+ * Why this route may not reach the invitee. Present whenever reachability is
56
+ * anything but `mesh`; surfaces must render it rather than imply the link
57
+ * works everywhere.
58
+ */
59
+ caveat?: string;
60
+ }
61
+
62
+ /** One concrete agent session that accepted one invitation. */
63
+ export interface ChannelInviteRedemption {
64
+ id: ScoutId;
65
+ /** The channel participant this redemption created or confirmed. */
66
+ actorId: ScoutId;
67
+ agentId?: ScoutId;
68
+ /** The pre-existing harness session attached to the channel. */
69
+ sessionId?: string;
70
+ endpointId?: ScoutId;
71
+ nodeId?: ScoutId;
72
+ harness?: string;
73
+ projectRoot?: string;
74
+ displayName?: string;
75
+ redeemedAt: number;
76
+ }
77
+
78
+ export interface ChannelInviteInvitee {
79
+ displayName: string;
80
+ handle?: string;
81
+ /** Set when the invitation names a durable person actor up front. */
82
+ actorId?: ScoutId;
83
+ }
84
+
85
+ export interface ChannelInviteRecord {
86
+ id: ScoutId;
87
+ channelId: ScoutId;
88
+ scope: ChannelInviteScope;
89
+ /** Lowercase hex digest of the raw token. The raw token is never stored. */
90
+ tokenHash: string;
91
+ /** A short non-secret fragment so humans can tell two invitations apart. */
92
+ tokenHint: string;
93
+ createdAt: number;
94
+ createdByActorId: ScoutId;
95
+ expiresAt: number | null;
96
+ maxRedemptions: number | null;
97
+ revokedAt?: number;
98
+ revokedByActorId?: ScoutId;
99
+ invitee?: ChannelInviteInvitee;
100
+ route: ChannelInviteRoute;
101
+ redemptions: ChannelInviteRedemption[];
102
+ metadata?: MetadataMap;
103
+ }
104
+
105
+ export type ChannelInviteState = "active" | "expired" | "revoked" | "exhausted";
106
+
107
+ export function channelInviteState(
108
+ invite: ChannelInviteRecord,
109
+ nowMs: number,
110
+ ): ChannelInviteState {
111
+ if (invite.revokedAt !== undefined) return "revoked";
112
+ if (invite.expiresAt !== null && invite.expiresAt <= nowMs) return "expired";
113
+ if (
114
+ invite.maxRedemptions !== null
115
+ && invite.redemptions.length >= invite.maxRedemptions
116
+ ) {
117
+ return "exhausted";
118
+ }
119
+ return "active";
120
+ }
121
+
122
+ export type ChannelInviteRejectionReason =
123
+ | "unknown_token"
124
+ | "expired"
125
+ | "revoked"
126
+ | "exhausted"
127
+ | "scope_mismatch"
128
+ | "channel_mismatch"
129
+ | "missing_identity";
130
+
131
+ export interface ChannelInviteRejection {
132
+ reason: ChannelInviteRejectionReason;
133
+ message: string;
134
+ }
135
+
136
+ export interface ChannelInviteRedemptionRequest {
137
+ actorId: ScoutId;
138
+ agentId?: ScoutId;
139
+ sessionId?: string;
140
+ endpointId?: ScoutId;
141
+ nodeId?: ScoutId;
142
+ harness?: string;
143
+ projectRoot?: string;
144
+ displayName?: string;
145
+ channelId?: ScoutId;
146
+ scope?: string;
147
+ }
148
+
149
+ export type ChannelInviteRedemptionOutcome =
150
+ | {
151
+ ok: true;
152
+ /**
153
+ * The redemption already on file for this identity. Present on a retry;
154
+ * the caller must reuse it rather than appending a second row or
155
+ * consuming another slot.
156
+ */
157
+ existing: ChannelInviteRedemption | null;
158
+ }
159
+ | { ok: false; rejection: ChannelInviteRejection };
160
+
161
+ /**
162
+ * Decide whether one redemption attempt may proceed.
163
+ *
164
+ * Retries are first-class: an identity that already redeemed this invitation
165
+ * is accepted again with its original redemption, even once the invitation is
166
+ * exhausted. Re-running an agent's join command must never mint a second
167
+ * identity, burn a second slot, or replace a live session.
168
+ */
169
+ export function evaluateChannelInviteRedemption(input: {
170
+ invite: ChannelInviteRecord;
171
+ request: ChannelInviteRedemptionRequest;
172
+ nowMs: number;
173
+ }): ChannelInviteRedemptionOutcome {
174
+ const { invite, request, nowMs } = input;
175
+
176
+ const actorId = request.actorId?.trim();
177
+ if (!actorId) {
178
+ return {
179
+ ok: false,
180
+ rejection: {
181
+ reason: "missing_identity",
182
+ message: "Redemption needs the actor id of the agent session that is joining.",
183
+ },
184
+ };
185
+ }
186
+
187
+ if (request.channelId?.trim() && request.channelId.trim() !== invite.channelId) {
188
+ return {
189
+ ok: false,
190
+ rejection: {
191
+ reason: "channel_mismatch",
192
+ message: "This invitation does not belong to the requested channel.",
193
+ },
194
+ };
195
+ }
196
+
197
+ if (request.scope?.trim() && request.scope.trim() !== invite.scope) {
198
+ return {
199
+ ok: false,
200
+ rejection: {
201
+ reason: "scope_mismatch",
202
+ message: `This invitation grants ${invite.scope} only.`,
203
+ },
204
+ };
205
+ }
206
+
207
+ const existing = findChannelInviteRedemption(invite, request);
208
+ if (existing) {
209
+ // Revocation still bites: a revoked invitation stops being a usable
210
+ // capability even for an identity that once held it.
211
+ if (invite.revokedAt !== undefined) {
212
+ return { ok: false, rejection: channelInviteRejection("revoked") };
213
+ }
214
+ return { ok: true, existing };
215
+ }
216
+
217
+ const state = channelInviteState(invite, nowMs);
218
+ if (state !== "active") {
219
+ return { ok: false, rejection: channelInviteRejection(state) };
220
+ }
221
+
222
+ return { ok: true, existing: null };
223
+ }
224
+
225
+ /**
226
+ * The redemption already recorded for this identity, if any.
227
+ *
228
+ * **Session identity is decisive.** When a request carries a session id, only a
229
+ * redemption for that exact session counts as the same joiner. A durable agent
230
+ * running two concrete sessions is two distinct attachments: this feature
231
+ * exists to attach *the exact session* a teammate is already working in, so
232
+ * letting a second session inherit the first one's membership through a shared
233
+ * agent id would hand it the wrong context and silently skip a use of the
234
+ * invitation.
235
+ *
236
+ * Only when no session is carried does the durable actor or agent id decide.
237
+ */
238
+ export function findChannelInviteRedemption(
239
+ invite: ChannelInviteRecord,
240
+ request: Pick<ChannelInviteRedemptionRequest, "actorId" | "sessionId" | "agentId">,
241
+ ): ChannelInviteRedemption | null {
242
+ const sessionId = request.sessionId?.trim();
243
+ if (sessionId) {
244
+ return invite.redemptions.find(
245
+ (redemption) => redemption.sessionId?.trim() === sessionId,
246
+ ) ?? null;
247
+ }
248
+
249
+ const actorId = request.actorId?.trim();
250
+ const agentId = request.agentId?.trim();
251
+ return invite.redemptions.find((redemption) => {
252
+ if (actorId && redemption.actorId === actorId) return true;
253
+ return Boolean(agentId) && redemption.agentId?.trim() === agentId;
254
+ }) ?? null;
255
+ }
256
+
257
+ function channelInviteRejection(
258
+ state: Exclude<ChannelInviteState, "active">,
259
+ ): ChannelInviteRejection {
260
+ switch (state) {
261
+ case "expired":
262
+ return { reason: "expired", message: "This invitation has expired. Ask for a new one." };
263
+ case "revoked":
264
+ return { reason: "revoked", message: "This invitation was revoked." };
265
+ case "exhausted":
266
+ return {
267
+ reason: "exhausted",
268
+ message: "This invitation has already been used the maximum number of times.",
269
+ };
270
+ }
271
+ }
272
+
273
+ export function unknownChannelInviteRejection(): ChannelInviteRejection {
274
+ // Deliberately identical for a malformed, unknown, and deleted token: a
275
+ // probe must not learn which invitations exist.
276
+ return { reason: "unknown_token", message: "This invitation link is not valid." };
277
+ }
278
+
279
+ /** Read the invitations stored on a conversation record. */
280
+ export function readChannelInvites(
281
+ metadata: MetadataMap | undefined,
282
+ ): ChannelInviteRecord[] {
283
+ const raw = metadata?.[CHANNEL_INVITES_METADATA_KEY];
284
+ if (!Array.isArray(raw)) return [];
285
+ return raw.filter((entry): entry is ChannelInviteRecord =>
286
+ Boolean(entry)
287
+ && typeof entry === "object"
288
+ && typeof (entry as ChannelInviteRecord).id === "string"
289
+ && typeof (entry as ChannelInviteRecord).tokenHash === "string"
290
+ && Array.isArray((entry as ChannelInviteRecord).redemptions));
291
+ }
292
+
293
+ /** Match a presented token against stored invitations by its digest. */
294
+ export function findChannelInviteByTokenHash(
295
+ invites: ChannelInviteRecord[],
296
+ tokenHash: string,
297
+ ): ChannelInviteRecord | null {
298
+ const normalized = tokenHash.trim().toLowerCase();
299
+ if (!normalized) return null;
300
+ return invites.find((invite) => invite.tokenHash.toLowerCase() === normalized) ?? null;
301
+ }
302
+
303
+ /**
304
+ * The public view of an invitation: everything a human or agent needs to
305
+ * understand the offer, and nothing that could be replayed into membership.
306
+ */
307
+ export interface ChannelInvitePublicView {
308
+ id: ScoutId;
309
+ channelId: ScoutId;
310
+ /** Public authorship, used to show who may revoke this invitation. */
311
+ createdByActorId: ScoutId;
312
+ scope: ChannelInviteScope;
313
+ state: ChannelInviteState;
314
+ tokenHint: string;
315
+ createdAt: number;
316
+ expiresAt: number | null;
317
+ maxRedemptions: number | null;
318
+ redemptionCount: number;
319
+ invitee?: ChannelInviteInvitee;
320
+ route: ChannelInviteRoute;
321
+ redemptions: ChannelInviteRedemption[];
322
+ }
323
+
324
+ export function channelInvitePublicView(
325
+ invite: ChannelInviteRecord,
326
+ nowMs: number,
327
+ ): ChannelInvitePublicView {
328
+ return {
329
+ id: invite.id,
330
+ channelId: invite.channelId,
331
+ createdByActorId: invite.createdByActorId,
332
+ scope: invite.scope,
333
+ state: channelInviteState(invite, nowMs),
334
+ tokenHint: invite.tokenHint,
335
+ createdAt: invite.createdAt,
336
+ expiresAt: invite.expiresAt,
337
+ maxRedemptions: invite.maxRedemptions,
338
+ redemptionCount: invite.redemptions.length,
339
+ ...(invite.invitee ? { invitee: invite.invitee } : {}),
340
+ route: invite.route,
341
+ redemptions: invite.redemptions,
342
+ };
343
+ }