@sigrun/ext-gateway 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +80 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +8 -0
  65. package/dist/session-meta.js +21 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +61 -0
  69. package/dist/state/identities.js +131 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +152 -0
  73. package/dist/state/ledger.js +207 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Self-linking (spec §8.2): the webui/TUI `/link <channel>` command
3
+ * issues a single-use code bound to the invoking user and channel, and
4
+ * the chat-side `redeemLink` (pipeline step 4) consumes it in a DM.
5
+ *
6
+ * Proof by holding both sessions: the code is the only bridge, so it is
7
+ * single-use, channel-bound, expires with pairing codes (3600000 ms),
8
+ * and a code exposed in a GROUP is invalidated (other members saw it —
9
+ * M9; the group refusal lives in the pipeline step, which owns the
10
+ * chat-shape check).
11
+ *
12
+ * A sender already linked to another user gets core's conflict refusal
13
+ * and the pinned reply; the code is NOT consumed until the link lands,
14
+ * so the user can remove the conflicting link and send the SAME code.
15
+ */
16
+ import { normalizeCode } from "../access/codes.js";
17
+ import { chatProviderOf } from "../state/identities.js";
18
+ /** The pinned refusal for an unnamed caller (spec §8.2). */
19
+ export const UNNAMED_CALLER = "run this as a named user";
20
+ /** The pinned conflict reply (the code stays live). */
21
+ export const LINK_CONFLICT_REPLY = "This chat account is already linked to another sigrun user. Remove that link under account links in sigrun, then send the code again.";
22
+ /** The pinned invalid-code reply. */
23
+ const INVALID_CODE_REPLY = "That code is not valid.";
24
+ /**
25
+ * Handle `/link <code>` from a DM: redeem, link, and return the reply
26
+ * text (the pipeline sends it). Core owns the conflict check; the
27
+ * static allowlist never blocks a self-link (it shadows the link
28
+ * afterward, exactly as any stale link).
29
+ */
30
+ export async function redeemLink(deps, channel, event) {
31
+ const code = normalizeCode(event.text.replace(/^\/link\s*/, ""));
32
+ const now = deps.now();
33
+ const user = deps.linkCodes.peek(code, channel, now);
34
+ if (user === undefined)
35
+ return INVALID_CODE_REPLY;
36
+ const linked = await deps.identities.link(channel, event.sender.id, user, "self", event.chat);
37
+ if (!linked.linked)
38
+ return LINK_CONFLICT_REPLY;
39
+ // Redeem only after the link succeeds: a conflict keeps the code live.
40
+ const redeemed = deps.linkCodes.redeem(code, channel, now);
41
+ if (redeemed === undefined) {
42
+ deps.log.warn(`self-link for user "${user}" on provider "${chatProviderOf(channel)}" is durable but code redemption failed`);
43
+ return INVALID_CODE_REPLY;
44
+ }
45
+ deps.log.info(`self-link landed on channel "${channel}" for user "${redeemed}"`);
46
+ return `You are linked to sigrun as ${redeemed}.`;
47
+ }
48
+ /** Build the `link` slash command over the engine's shared state. */
49
+ export function createLinkCommand(deps) {
50
+ return {
51
+ name: "link",
52
+ description: "Link this chat account to a channel with a single-use code",
53
+ args: "<channel>",
54
+ run: async (ctx) => {
55
+ if (ctx.principal.user === undefined) {
56
+ return { notice: UNNAMED_CALLER };
57
+ }
58
+ const channel = ctx.args.trim();
59
+ if (channel === "") {
60
+ return { notice: "usage: /link <channel>" };
61
+ }
62
+ if (!deps.knownChannels().includes(channel)) {
63
+ return { notice: `unknown channel ${channel}.` };
64
+ }
65
+ const config = await deps.readConfig();
66
+ const block = config.channels[channel];
67
+ if (block === undefined || "failed" in block || !block.enabled) {
68
+ return { notice: `channel ${channel} is disabled.` };
69
+ }
70
+ const code = deps.linkCodes.issue(ctx.principal.user, channel, deps.now());
71
+ return {
72
+ notice: `Link code ${code}. Send "/link ${code}" to the bot in a direct ` +
73
+ `message on ${channel} to finish linking.`,
74
+ };
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The `/pair` command (spec §8.2): the admin surface for pending
3
+ * pairing requests, runnable from any webui/TUI conversation through
4
+ * the extension-command invoke route (`POST /v1/conversations/:id/
5
+ * commands/pair`) — and, through the same decision functions, from
6
+ * chat via the pairing-notice buttons (§8.2: every admin's home DM
7
+ * gets the request; the buttons carry opaque tokens the pipeline
8
+ * resolves).
9
+ *
10
+ * Admin-only (EFFECTIVE admin — the caller's principal carries it from
11
+ * the stored flag OR the persisted group grant, C8). An unnamed caller
12
+ * (the no-auth loopback method) is refused with the pinned
13
+ * "run this as a named user".
14
+ *
15
+ * `approve <code> <user>` links the REQUESTER (the platform sender who
16
+ * asked to pair) to the named directory user via the identity store
17
+ * (`via: "pairing"`), sets their DM as the home chat, and sends them a
18
+ * "linked" message through their channel. Core refuses a sender already
19
+ * linked to another user; the request then stays pending for an admin
20
+ * to resolve (deny, or remove the link).
21
+ */
22
+ import type { ChannelContribution } from "@sigrun/sigrun/extension";
23
+ import type { EngineLog } from "../log.js";
24
+ import type { IdentityStore } from "../state/identities.js";
25
+ import type { PairingStore } from "../access/pairing.js";
26
+ /** The `/pair` decision path's runtime dependencies (the engine wires them). */
27
+ export interface PairDeps {
28
+ /** The host: the directory the approved user must exist in. */
29
+ readonly host: {
30
+ readonly users: {
31
+ list(): Promise<readonly {
32
+ readonly user: string;
33
+ }[]>;
34
+ };
35
+ };
36
+ readonly identities: IdentityStore;
37
+ readonly pairing: PairingStore;
38
+ /**
39
+ * The channel adapter for `channelOf(id)` — the "linked" reply rides
40
+ * the requester's own channel (undefined: the channel went away; the
41
+ * reply is skipped, the link still lands).
42
+ */
43
+ readonly channelOf: (channel: string) => ChannelContribution | undefined;
44
+ readonly log: EngineLog;
45
+ readonly now: () => number;
46
+ }
47
+ /** Who invoked the command. */
48
+ export interface PairCaller {
49
+ readonly user: string | undefined;
50
+ readonly admin: boolean;
51
+ }
52
+ /** The pinned refusal for an unnamed caller (spec §8.2). */
53
+ export declare const UNNAMED_CALLER = "run this as a named user";
54
+ /** The pinned conflict reply (spec §8.2: never silently re-pointed). */
55
+ export declare const PAIR_CONFLICT_REPLY = "That sender is already linked to another user.";
56
+ /**
57
+ * Run `/pair` with `args` as `caller`. Every path resolves a string
58
+ * reply (the command's notice); nothing throws across the boundary.
59
+ */
60
+ export declare function runPair(deps: PairDeps, args: string, caller: PairCaller): Promise<string>;
61
+ /**
62
+ * Apply `deny <code>` (the command and the button share this path).
63
+ * Returns the decision reply for the admin surface.
64
+ */
65
+ export declare function denyPairing(deps: PairDeps, code: string): string;
66
+ /**
67
+ * Apply `approve <code> <user>` (the command and the "Approve as"
68
+ * buttons share this path): directory check, core-backed link, then
69
+ * consume the queue row and send the requester's "linked" message.
70
+ * Core's conflict refusal leaves the request pending.
71
+ */
72
+ export declare function approvePairing(deps: PairDeps, code: string, namedUser: string): Promise<string>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The `/pair` command (spec §8.2): the admin surface for pending
3
+ * pairing requests, runnable from any webui/TUI conversation through
4
+ * the extension-command invoke route (`POST /v1/conversations/:id/
5
+ * commands/pair`) — and, through the same decision functions, from
6
+ * chat via the pairing-notice buttons (§8.2: every admin's home DM
7
+ * gets the request; the buttons carry opaque tokens the pipeline
8
+ * resolves).
9
+ *
10
+ * Admin-only (EFFECTIVE admin — the caller's principal carries it from
11
+ * the stored flag OR the persisted group grant, C8). An unnamed caller
12
+ * (the no-auth loopback method) is refused with the pinned
13
+ * "run this as a named user".
14
+ *
15
+ * `approve <code> <user>` links the REQUESTER (the platform sender who
16
+ * asked to pair) to the named directory user via the identity store
17
+ * (`via: "pairing"`), sets their DM as the home chat, and sends them a
18
+ * "linked" message through their channel. Core refuses a sender already
19
+ * linked to another user; the request then stays pending for an admin
20
+ * to resolve (deny, or remove the link).
21
+ */
22
+ import { normalizeCode } from "../access/codes.js";
23
+ /** The pinned refusal for an unnamed caller (spec §8.2). */
24
+ export const UNNAMED_CALLER = "run this as a named user";
25
+ /** The pinned conflict reply (spec §8.2: never silently re-pointed). */
26
+ export const PAIR_CONFLICT_REPLY = "That sender is already linked to another user.";
27
+ /** The usage line (the args grammar, one line). */
28
+ const USAGE = "usage: /pair [approve <code> <user> | deny <code>]";
29
+ /**
30
+ * Run `/pair` with `args` as `caller`. Every path resolves a string
31
+ * reply (the command's notice); nothing throws across the boundary.
32
+ */
33
+ export async function runPair(deps, args, caller) {
34
+ if (caller.user === undefined)
35
+ return UNNAMED_CALLER;
36
+ if (!caller.admin) {
37
+ return "only admins can manage pairing requests.";
38
+ }
39
+ const parts = args.trim().split(/\s+/).filter((part) => part !== "");
40
+ if (parts.length === 0)
41
+ return listPending(deps);
42
+ const verb = parts[0];
43
+ if (verb === "deny" && parts.length === 2) {
44
+ return denyPairing(deps, normalizeCode(parts[1] ?? ""));
45
+ }
46
+ if (verb === "approve" && parts.length === 3) {
47
+ return approvePairing(deps, normalizeCode(parts[1] ?? ""), parts[2] ?? "");
48
+ }
49
+ return USAGE;
50
+ }
51
+ /** `""` — one line per pending request (channel, sender, code, age). */
52
+ function listPending(deps) {
53
+ const now = deps.now();
54
+ const rows = deps.pairing.pending();
55
+ if (rows.length === 0)
56
+ return "no pending pairing requests.";
57
+ return rows
58
+ .map((row) => {
59
+ const minutes = Math.max(0, Math.round((now - row.createdAt) / 60_000));
60
+ const who = row.senderName === undefined ? row.senderId : row.senderName;
61
+ return `${row.channel}: ${who} — /pair approve ${row.code} <user> (${minutes}m ago)`;
62
+ })
63
+ .join("\n");
64
+ }
65
+ /**
66
+ * Apply `deny <code>` (the command and the button share this path).
67
+ * Returns the decision reply for the admin surface.
68
+ */
69
+ export function denyPairing(deps, code) {
70
+ if (deps.pairing.deny(code))
71
+ return `pairing request ${code} denied.`;
72
+ return `unknown pairing code ${code}.`;
73
+ }
74
+ /**
75
+ * Apply `approve <code> <user>` (the command and the "Approve as"
76
+ * buttons share this path): directory check, core-backed link, then
77
+ * consume the queue row and send the requester's "linked" message.
78
+ * Core's conflict refusal leaves the request pending.
79
+ */
80
+ export async function approvePairing(deps, code, namedUser) {
81
+ const directory = await deps.host.users.list();
82
+ if (!directory.some((row) => row.user === namedUser)) {
83
+ return `unknown user ${namedUser}.`;
84
+ }
85
+ // The pre-approve read: find the row WITHOUT consuming it.
86
+ const request = deps.pairing.pending().find((row) => row.code === code);
87
+ if (request === undefined) {
88
+ return `unknown pairing code ${code}.`;
89
+ }
90
+ const linked = await deps.identities.link(request.channel, request.senderId, namedUser, "pairing", request.chat);
91
+ if (!linked.linked)
92
+ return PAIR_CONFLICT_REPLY;
93
+ deps.pairing.approve(code, deps.now());
94
+ deps.log.info(`pairing approved on channel "${request.channel}" for user "${namedUser}"`);
95
+ const channel = deps.channelOf(request.channel);
96
+ if (channel !== undefined) {
97
+ try {
98
+ await channel.send({
99
+ chat: request.chat,
100
+ text: `You are linked to sigrun as ${namedUser}.`,
101
+ });
102
+ }
103
+ catch (error) {
104
+ // The link landed; the celebratory notice is best-effort (a
105
+ // down channel never rolls back an approval).
106
+ deps.log.error("pairing linked-notice send failed", error);
107
+ }
108
+ }
109
+ return `linked ${request.senderName ?? request.senderId} to ${namedUser}.`;
110
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Gateway configuration (spec §8.1): `settings/gateway.json` through the
3
+ * engine's scoped settings store, parsed PER READ (live config — every
4
+ * field except a group's `tools`/`extensions` is read per event).
5
+ *
6
+ * Malformed-config posture: a malformed block for one channel or group
7
+ * disables THAT channel or group with the reason (the `{failed}` forms)
8
+ * and never affects its siblings; malformed top-level scalars fall back
9
+ * to their defaults. The parser never throws.
10
+ *
11
+ * Types are imported from `@sigrun/sigrun/extension` type-only (the
12
+ * types-in-main-package contract); this module's runtime imports are
13
+ * none — it is a pure function over a parsed-JSON object.
14
+ */
15
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
16
+ /** The DM admission policy (spec §8.2). */
17
+ export type DmPolicy = "pairing" | "allowlist" | "disabled";
18
+ /** One group chat's config (spec §8.1). */
19
+ export interface GroupConfig {
20
+ readonly chat: string;
21
+ readonly owner: string;
22
+ readonly mention: "required" | "always";
23
+ readonly members: "linked" | "anyone";
24
+ readonly tools?: readonly string[];
25
+ readonly extensions?: readonly string[];
26
+ }
27
+ /** One channel's config (spec §8.1). */
28
+ export interface ChannelConfig {
29
+ readonly enabled: boolean;
30
+ readonly dm: DmPolicy;
31
+ readonly selfLink: boolean;
32
+ readonly allow: readonly {
33
+ sender: string;
34
+ user: string;
35
+ }[];
36
+ readonly groups: readonly (GroupConfig | {
37
+ readonly chat: string;
38
+ readonly failed: string;
39
+ })[];
40
+ readonly busy: "followUp" | "steer";
41
+ readonly stream: boolean;
42
+ readonly allowBots: boolean;
43
+ }
44
+ /** The whole `settings/gateway.json` shape (spec §8.1). */
45
+ export interface GatewayConfig {
46
+ readonly channels: Readonly<Record<string, ChannelConfig | {
47
+ readonly failed: string;
48
+ }>>;
49
+ readonly media: {
50
+ readonly maxBytes: number;
51
+ };
52
+ readonly notify: {
53
+ readonly home: boolean;
54
+ };
55
+ }
56
+ /** Default media cap (16 MiB — the readFile ceiling, spec §8.1). */
57
+ export declare const DEFAULT_MEDIA_MAX_BYTES = 16777216;
58
+ /** Parse the raw settings object into the validated {@link GatewayConfig}. */
59
+ export declare function parseGatewayConfig(raw: Record<string, unknown>): GatewayConfig;
60
+ /**
61
+ * Read the config LIVE through the engine's scoped settings store: the
62
+ * store's object parsed per call, so an operator's edit takes effect on
63
+ * the next event without a restart (only `enabled` is restart-required).
64
+ */
65
+ export declare function readConfig(host: SigrunHost): Promise<GatewayConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Gateway configuration (spec §8.1): `settings/gateway.json` through the
3
+ * engine's scoped settings store, parsed PER READ (live config — every
4
+ * field except a group's `tools`/`extensions` is read per event).
5
+ *
6
+ * Malformed-config posture: a malformed block for one channel or group
7
+ * disables THAT channel or group with the reason (the `{failed}` forms)
8
+ * and never affects its siblings; malformed top-level scalars fall back
9
+ * to their defaults. The parser never throws.
10
+ *
11
+ * Types are imported from `@sigrun/sigrun/extension` type-only (the
12
+ * types-in-main-package contract); this module's runtime imports are
13
+ * none — it is a pure function over a parsed-JSON object.
14
+ */
15
+ /** Default media cap (16 MiB — the readFile ceiling, spec §8.1). */
16
+ export const DEFAULT_MEDIA_MAX_BYTES = 16_777_216;
17
+ /** Parse the raw settings object into the validated {@link GatewayConfig}. */
18
+ export function parseGatewayConfig(raw) {
19
+ return {
20
+ channels: parseChannels(raw.channels),
21
+ media: parseMedia(raw.media),
22
+ notify: parseNotify(raw.notify),
23
+ };
24
+ }
25
+ /**
26
+ * Read the config LIVE through the engine's scoped settings store: the
27
+ * store's object parsed per call, so an operator's edit takes effect on
28
+ * the next event without a restart (only `enabled` is restart-required).
29
+ */
30
+ export async function readConfig(host) {
31
+ const raw = await host.stores.settings.get();
32
+ return parseGatewayConfig(raw);
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Channel parsing
36
+ // ---------------------------------------------------------------------------
37
+ function parseChannels(value) {
38
+ if (!isRecord(value))
39
+ return {};
40
+ const channels = {};
41
+ for (const [id, block] of Object.entries(value)) {
42
+ channels[id] = parseChannel(block, id);
43
+ }
44
+ return channels;
45
+ }
46
+ function parseChannel(value, id) {
47
+ if (!isRecord(value)) {
48
+ return { failed: `channel "${id}" config is not an object` };
49
+ }
50
+ if (value.dm !== undefined && !isDmPolicy(value.dm)) {
51
+ return {
52
+ failed: `channel "${id}" dm must be "pairing" | "allowlist" | "disabled"`,
53
+ };
54
+ }
55
+ const dm = value.dm === undefined ? "pairing" : value.dm;
56
+ if (value.enabled !== undefined && typeof value.enabled !== "boolean") {
57
+ return { failed: `channel "${id}" enabled must be a boolean` };
58
+ }
59
+ if (value.busy !== undefined &&
60
+ value.busy !== "followUp" &&
61
+ value.busy !== "steer") {
62
+ return { failed: `channel "${id}" busy must be "followUp" | "steer"` };
63
+ }
64
+ const allow = parseAllow(value.allow, id);
65
+ if (typeof allow === "string")
66
+ return { failed: allow };
67
+ const groups = parseGroups(value.groups, id);
68
+ if (typeof groups === "string")
69
+ return { failed: groups };
70
+ if (value.selfLink !== undefined && typeof value.selfLink !== "boolean") {
71
+ return { failed: `channel "${id}" selfLink must be a boolean` };
72
+ }
73
+ if (value.stream !== undefined && typeof value.stream !== "boolean") {
74
+ return { failed: `channel "${id}" stream must be a boolean` };
75
+ }
76
+ if (value.allowBots !== undefined && typeof value.allowBots !== "boolean") {
77
+ return { failed: `channel "${id}" allowBots must be a boolean` };
78
+ }
79
+ // selfLink defaults true for pairing channels, false otherwise (spec
80
+ // §8.1: an allowlist channel's operator chose a closed list).
81
+ const selfLinkDefault = dm === "pairing";
82
+ return {
83
+ enabled: value.enabled === undefined ? true : value.enabled,
84
+ dm,
85
+ selfLink: value.selfLink === undefined ? selfLinkDefault : value.selfLink,
86
+ allow,
87
+ groups,
88
+ busy: value.busy === undefined ? "followUp" : value.busy,
89
+ stream: value.stream === undefined ? true : value.stream,
90
+ allowBots: value.allowBots === undefined ? false : value.allowBots,
91
+ };
92
+ }
93
+ function isDmPolicy(value) {
94
+ return value === "pairing" || value === "allowlist" || value === "disabled";
95
+ }
96
+ function parseAllow(value, id) {
97
+ if (value === undefined)
98
+ return [];
99
+ if (!Array.isArray(value)) {
100
+ return `channel "${id}" allow must be an array`;
101
+ }
102
+ const entries = [];
103
+ for (const entry of value) {
104
+ if (!isRecord(entry)) {
105
+ return `channel "${id}" allow entries must be objects`;
106
+ }
107
+ if (typeof entry.sender !== "string" || entry.sender === "") {
108
+ return `channel "${id}" allow entries need a non-empty string sender`;
109
+ }
110
+ if (typeof entry.user !== "string" || entry.user === "") {
111
+ return `channel "${id}" allow entries need a non-empty string user`;
112
+ }
113
+ entries.push({ sender: entry.sender, user: entry.user });
114
+ }
115
+ return entries;
116
+ }
117
+ function parseGroups(value, id) {
118
+ if (value === undefined)
119
+ return [];
120
+ if (!Array.isArray(value)) {
121
+ return `channel "${id}" groups must be an array`;
122
+ }
123
+ return value.map((group) => parseGroup(group));
124
+ }
125
+ function parseGroup(value) {
126
+ if (!isRecord(value)) {
127
+ return { chat: "", failed: "group config is not an object" };
128
+ }
129
+ // The failed-group form still names WHICH chat failed: an absent or
130
+ // non-string chat renders as the empty string so the row is shaped.
131
+ const chat = typeof value.chat === "string" ? value.chat : "";
132
+ if (chat === "") {
133
+ return { chat, failed: "group needs a non-empty string chat" };
134
+ }
135
+ if (typeof value.owner !== "string" || value.owner === "") {
136
+ return { chat, failed: `group "${chat}" needs a non-empty string owner` };
137
+ }
138
+ if (value.mention !== undefined &&
139
+ value.mention !== "required" &&
140
+ value.mention !== "always") {
141
+ return {
142
+ chat,
143
+ failed: `group "${chat}" mention must be "required" | "always"`,
144
+ };
145
+ }
146
+ if (value.members !== undefined &&
147
+ value.members !== "linked" &&
148
+ value.members !== "anyone") {
149
+ return {
150
+ chat,
151
+ failed: `group "${chat}" members must be "linked" | "anyone"`,
152
+ };
153
+ }
154
+ const members = value.members === undefined ? "linked" : value.members;
155
+ if (value.tools !== undefined && !isStringArray(value.tools)) {
156
+ return {
157
+ chat,
158
+ failed: `group "${chat}" tools must be an array of strings`,
159
+ };
160
+ }
161
+ if (value.extensions !== undefined && !isStringArray(value.extensions)) {
162
+ return {
163
+ chat,
164
+ failed: `group "${chat}" extensions must be an array of strings`,
165
+ };
166
+ }
167
+ const tools = value.tools;
168
+ let extensions = value.extensions;
169
+ if (members === "anyone") {
170
+ // Spec §8.1: "anyone" REQUIRES a non-empty tools array — an open
171
+ // group without a pinned tool set would run the full default agent
172
+ // for untrusted senders.
173
+ if (tools === undefined || tools.length === 0) {
174
+ return {
175
+ chat,
176
+ failed: `group "${chat}" members: "anyone" requires a non-empty tools array`,
177
+ };
178
+ }
179
+ // Spec §8.10: the gateway's own prompt section rides along on
180
+ // anyone groups (the model must see its chat context).
181
+ if (extensions !== undefined && !extensions.includes("gateway")) {
182
+ extensions = [...extensions, "gateway"];
183
+ }
184
+ }
185
+ return {
186
+ chat,
187
+ owner: value.owner,
188
+ mention: value.mention === undefined ? "required" : value.mention,
189
+ members,
190
+ ...(tools === undefined ? {} : { tools }),
191
+ ...(extensions === undefined ? {} : { extensions }),
192
+ };
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+ // Media / notify parsing
196
+ // ---------------------------------------------------------------------------
197
+ function parseMedia(value) {
198
+ if (isRecord(value) &&
199
+ typeof value.maxBytes === "number" &&
200
+ value.maxBytes > 0) {
201
+ return { maxBytes: value.maxBytes };
202
+ }
203
+ return { maxBytes: DEFAULT_MEDIA_MAX_BYTES };
204
+ }
205
+ function parseNotify(value) {
206
+ if (isRecord(value) && typeof value.home === "boolean") {
207
+ return { home: value.home };
208
+ }
209
+ return { home: true };
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Small helpers
213
+ // ---------------------------------------------------------------------------
214
+ function isRecord(value) {
215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
216
+ }
217
+ function isStringArray(value) {
218
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
219
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The gateway engine (spec §8.0, gateway C2): chat-transport supervision
3
+ * over the boot's channel bindings. ONE engine per boot; `start` runs
4
+ * after the server listens, `stopIntake` before the shutdown drain,
5
+ * `stop` after it.
6
+ *
7
+ * Task 2 owns supervision: `start` reads the LIVE config once for the
8
+ * restart-required enablement (a channel absent from `channels`, an
9
+ * `enabled: false`, or a `{failed}` config block never starts its
10
+ * adapter — the engine records a static row instead), then hands every
11
+ * enabled channel a {@link Supervisor}. Health (§8.12) reads the
12
+ * supervisors' unhealthy dwell plus the ledger's failed-in-last-hour
13
+ * count (Task 5: the engine-owned ledger feeds the `failedRecently`
14
+ * seam — tests may still override it).
15
+ *
16
+ * The inbound pipeline (Task 3) drives `receive` (§8.4): the config
17
+ * block is read LIVE per event, admission runs the ordered steps, and
18
+ * the supervisor counts every outcome. `action` (button taps) stays a
19
+ * no-op until the token table lands (Tasks 4/9).
20
+ *
21
+ * `EngineInternals` is the test/task-internal surface (the state-dir
22
+ * path, the status read) — NOT part of the loader contract, which sees
23
+ * only the {@link GatewayEngine} four.
24
+ */
25
+ import type { GatewayEngine, SigrunHost } from "@sigrun/sigrun/extension";
26
+ import { type EngineLog } from "./log.js";
27
+ import { createPipeline } from "./inbound/pipeline.js";
28
+ import { type ChannelStatus } from "./supervisor.js";
29
+ import type { PairDeps } from "./commands/pair.js";
30
+ import { Ledger } from "./state/ledger.js";
31
+ import type { AccessState } from "./inbound/pipeline.js";
32
+ import type { StatusPairingRow } from "./status-route.js";
33
+ import type { ToolDeps } from "./tools.js";
34
+ import type { ChannelSectionDeps } from "./section.js";
35
+ /**
36
+ * The engine's injectable dependencies (tests and later tasks). Task 1
37
+ * carried only the log; every later seam lands here so the constructor
38
+ * signature never changes twice.
39
+ */
40
+ export interface EngineDeps {
41
+ readonly log?: EngineLog;
42
+ /** The timer bundle supervision rides. Default: real clock, real
43
+ * timers, crypto random (the factory never wires a timer at
44
+ * construction — the pi lifecycle rule; the supervisor starts them
45
+ * only from its `start`). */
46
+ readonly timers?: {
47
+ now(): number;
48
+ random(): number;
49
+ setTimer(fn: () => void, ms: number): () => void;
50
+ };
51
+ /**
52
+ * The ledger's failed-in-last-hour count (§8.12's second health
53
+ * input). Default: the engine's OWN ledger over the real clock —
54
+ * tests inject a stub for the pre-ledger engine tests.
55
+ */
56
+ readonly failedRecently?: () => number;
57
+ /** Test/task-internal ledger seam for shutdown and recovery tests. */
58
+ readonly deliveryLedger?: Ledger;
59
+ /** Construction-count test seam; production uses the real pipeline. */
60
+ readonly pipelineFactory?: typeof createPipeline;
61
+ /** Test seam for the receipt wait; production keeps the pinned 30 s. */
62
+ readonly toolReceiptWaitMs?: number;
63
+ }
64
+ /** The task-internal surface over the contract four (see the module doc). */
65
+ export interface EngineInternals {
66
+ /** The engine's state directory: `<stateDir>/gateway` (the scoped
67
+ * `host.stores.paths.state` plus the engine's own segment). */
68
+ readonly stateDir: string;
69
+ /** The channel ids the last `start` recorded, in order. */
70
+ readonly channelIds: () => readonly string[];
71
+ /**
72
+ * The status route's read (Task 2): every channel's current row —
73
+ * supervised and static (config-error/disabled) alike.
74
+ */
75
+ status(): {
76
+ readonly channels: Readonly<Record<string, ChannelStatus>>;
77
+ };
78
+ /**
79
+ * The status route's pairing read (Task 4): every live pending
80
+ * request WITHOUT the code (hashed sender — §8.12's rule that
81
+ * operator output never carries raw platform ids).
82
+ */
83
+ pairingStatus(): readonly StatusPairingRow[];
84
+ /** The engine-wide access state (pipelines and commands share it). */
85
+ accessState(): AccessState;
86
+ /** The `/pair` decision deps over the shared state (the factory's
87
+ * command closes over these — `host` is the factory's own host). */
88
+ pairDeps(host: SigrunHost): PairDeps;
89
+ /**
90
+ * The settled-run fan-out (the factory's `runSettled` closes over
91
+ * this): enqueues the finals/notices, then kicks the affected
92
+ * channels' workers.
93
+ */
94
+ settle(event: import("@sigrun/sigrun/extension").RunSettledEvent, api: import("@sigrun/sigrun/extension").MemoryEventApi, context: import("@sigrun/sigrun/extension").HandlerContext): Promise<void>;
95
+ /** The home-chat notifier and its enqueue-then-kick delivery path. */
96
+ notifier(): import("@sigrun/sigrun/extension").NotifierContribution;
97
+ /** Delivered prompt edits and bound-chat outcome notices. */
98
+ interactionSettled(event: import("@sigrun/sigrun/extension").ExtensionLifecyclePayload["interactionSettled"], context: import("@sigrun/sigrun/extension").HandlerContext): Promise<void>;
99
+ /** Agent tools and bound-chat prompt section over the same shared stores. */
100
+ toolDeps(): ToolDeps;
101
+ sectionDeps(): ChannelSectionDeps;
102
+ }
103
+ /**
104
+ * Build the engine over the host. The factory wires the real
105
+ * dependencies; tests inject the clock/log/timer seams through the same
106
+ * parameter.
107
+ */
108
+ export declare function createEngine(host: SigrunHost, deps?: EngineDeps): GatewayEngine & EngineInternals;