@forwardimpact/libbridge 0.1.1 → 0.1.3
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/package.json +3 -3
- package/src/callback-handler.js +14 -1
- package/src/callback-payload.js +47 -3
- package/src/index.js +2 -0
- package/src/origin-index.js +49 -0
- package/src/progress-ticker.js +4 -2
- package/src/resume-scheduler.js +5 -7
- package/src/server.js +12 -0
- package/src/triggers.js +20 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Threaded-channel bridge primitives — relay messages between human channels (GitHub Discussions, Microsoft Teams) and the Kata agent team.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bridge",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@forwardimpact/libindex": "^0.1.38",
|
|
44
44
|
"@forwardimpact/libstorage": "^0.1.78",
|
|
45
|
-
"@hono/node-server": "^2.0.
|
|
46
|
-
"hono": "^4.12.
|
|
45
|
+
"@hono/node-server": "^2.0.4",
|
|
46
|
+
"hono": "^4.12.23"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@forwardimpact/libharness": "^0.1.21"
|
package/src/callback-handler.js
CHANGED
|
@@ -134,6 +134,19 @@ async function resolveContext(
|
|
|
134
134
|
return { token, ctx, meta, payload };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
const STATUS_MESSAGES = {
|
|
138
|
+
400: "Bad request",
|
|
139
|
+
404: "Not found",
|
|
140
|
+
410: "Gone",
|
|
141
|
+
429: "Too many requests",
|
|
142
|
+
500: "Internal error",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Map a CallbackHandlerError status to a safe generic message. */
|
|
146
|
+
function sanitizeErrorMessage(status) {
|
|
147
|
+
return STATUS_MESSAGES[status] ?? `Error ${status}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
async function runHandleReply(
|
|
138
151
|
c,
|
|
139
152
|
{ ctx, meta, payload, handleReply, store, logger, tracer, spanName },
|
|
@@ -154,7 +167,7 @@ async function runHandleReply(
|
|
|
154
167
|
if (err instanceof CallbackHandlerError) {
|
|
155
168
|
span.addEvent("short_circuit", { status: err.status });
|
|
156
169
|
span.setOk();
|
|
157
|
-
return c.json({ error: err.
|
|
170
|
+
return c.json({ error: sanitizeErrorMessage(err.status) }, err.status);
|
|
158
171
|
}
|
|
159
172
|
logger.error?.("callback", err, {
|
|
160
173
|
correlation_id: meta.correlationId,
|
package/src/callback-payload.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const MAX_FIELD_LENGTH = 2000;
|
|
2
|
+
export const MAX_REPLY_COUNT = 50;
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Validate and sanitize the kata-dispatch callback payload. Lenient by
|
|
@@ -23,11 +24,17 @@ export function validateCallbackPayload(body) {
|
|
|
23
24
|
typeof body.summary === "string"
|
|
24
25
|
? body.summary.slice(0, MAX_FIELD_LENGTH)
|
|
25
26
|
: "";
|
|
26
|
-
const
|
|
27
|
+
const rawReplies = Array.isArray(body.replies) ? body.replies : [];
|
|
28
|
+
const replies = rawReplies.slice(0, MAX_REPLY_COUNT).map((r) => {
|
|
29
|
+
if (!r || typeof r !== "object") return r;
|
|
30
|
+
if (typeof r.body === "string") {
|
|
31
|
+
return { ...r, body: r.body.slice(0, MAX_FIELD_LENGTH) };
|
|
32
|
+
}
|
|
33
|
+
return r;
|
|
34
|
+
});
|
|
27
35
|
const discussionId =
|
|
28
36
|
typeof body.discussion_id === "string" ? body.discussion_id : undefined;
|
|
29
|
-
const trigger =
|
|
30
|
-
body.trigger && typeof body.trigger === "object" ? body.trigger : undefined;
|
|
37
|
+
const trigger = validateTrigger(body.trigger);
|
|
31
38
|
const runUrl = typeof body.run_url === "string" ? body.run_url : undefined;
|
|
32
39
|
|
|
33
40
|
return {
|
|
@@ -41,6 +48,43 @@ export function validateCallbackPayload(body) {
|
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
const ALLOWED_TRIGGER_KINDS = new Set([
|
|
52
|
+
"missing_input",
|
|
53
|
+
"escalation_needed",
|
|
54
|
+
"elapsed",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const TRIGGER_FIELD_VALIDATORS = {
|
|
58
|
+
replies: (raw) => {
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
61
|
+
},
|
|
62
|
+
elapsed: (raw) => (typeof raw === "string" ? raw : undefined),
|
|
63
|
+
signal: (raw) => (typeof raw === "string" && raw ? raw : undefined),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate and sanitize a trigger object at the payload boundary.
|
|
68
|
+
* Rejects triggers with unknown `kind` values or invalid field types.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} raw
|
|
71
|
+
* @returns {object | undefined}
|
|
72
|
+
*/
|
|
73
|
+
function validateTrigger(raw) {
|
|
74
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
75
|
+
if (typeof raw.kind !== "string" || !ALLOWED_TRIGGER_KINDS.has(raw.kind)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const trigger = { kind: raw.kind };
|
|
79
|
+
for (const [field, validate] of Object.entries(TRIGGER_FIELD_VALIDATORS)) {
|
|
80
|
+
if (raw[field] === undefined) continue;
|
|
81
|
+
const clean = validate(raw[field]);
|
|
82
|
+
if (clean === undefined) return undefined;
|
|
83
|
+
trigger[field] = clean;
|
|
84
|
+
}
|
|
85
|
+
return trigger;
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
/**
|
|
45
89
|
* Strip trailing slashes from a base URL so callback URL composition is
|
|
46
90
|
* deterministic regardless of operator input.
|
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { appendHistory } from "./history.js";
|
|
|
17
17
|
export { RateLimiter } from "./rate-limit.js";
|
|
18
18
|
export { dispatchWorkflow } from "./dispatch.js";
|
|
19
19
|
export { DiscussionContextStore } from "./discussion-context.js";
|
|
20
|
+
export { OriginIndex } from "./origin-index.js";
|
|
20
21
|
export { ProgressTicker } from "./progress-ticker.js";
|
|
21
22
|
export {
|
|
22
23
|
Acknowledgement,
|
|
@@ -31,6 +32,7 @@ export { ElapsedScheduler } from "./elapsed-scheduler.js";
|
|
|
31
32
|
export { ResumeScheduler } from "./resume-scheduler.js";
|
|
32
33
|
export {
|
|
33
34
|
MAX_FIELD_LENGTH,
|
|
35
|
+
MAX_REPLY_COUNT,
|
|
34
36
|
newDiscussionContext,
|
|
35
37
|
normalizeBaseUrl,
|
|
36
38
|
validateCallbackPayload,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BufferedIndex } from "@forwardimpact/libindex";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tracks comment IDs posted by the bridge so webhook handlers can skip
|
|
7
|
+
* dispatching for self-originated events. Wraps `BufferedIndex` with
|
|
8
|
+
* caller-injected `StorageInterface` per the libbridge invariant.
|
|
9
|
+
*
|
|
10
|
+
* Record shape: `{ id: "<comment_node_id>", discussion_id, posted_at }`
|
|
11
|
+
*
|
|
12
|
+
* @augments BufferedIndex
|
|
13
|
+
*/
|
|
14
|
+
export class OriginIndex extends BufferedIndex {
|
|
15
|
+
#ttlMs;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import("@forwardimpact/libstorage").StorageInterface} storage
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {string} [options.indexKey]
|
|
21
|
+
* @param {number} [options.ttlMs] - Eviction window (default 24h)
|
|
22
|
+
*/
|
|
23
|
+
constructor(
|
|
24
|
+
storage,
|
|
25
|
+
{ indexKey = "origins.jsonl", ttlMs = DEFAULT_TTL_MS } = {},
|
|
26
|
+
) {
|
|
27
|
+
super(storage, indexKey, {
|
|
28
|
+
flush_interval: 1_000,
|
|
29
|
+
max_buffer_size: 100,
|
|
30
|
+
});
|
|
31
|
+
this.#ttlMs = ttlMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evict records older than `ttlMs`.
|
|
36
|
+
* @param {number} now
|
|
37
|
+
* @returns {number} count evicted
|
|
38
|
+
*/
|
|
39
|
+
sweep(now) {
|
|
40
|
+
let evicted = 0;
|
|
41
|
+
for (const [id, record] of this.index) {
|
|
42
|
+
if (now - (record?.posted_at ?? 0) > this.#ttlMs) {
|
|
43
|
+
this.index.delete(id);
|
|
44
|
+
evicted++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return evicted;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/progress-ticker.js
CHANGED
|
@@ -37,15 +37,17 @@ export class ProgressTicker {
|
|
|
37
37
|
throw new Error("tick must be a function");
|
|
38
38
|
}
|
|
39
39
|
this.stop(token);
|
|
40
|
-
const
|
|
40
|
+
const safeTick = async () => {
|
|
41
41
|
try {
|
|
42
42
|
await tick();
|
|
43
43
|
} catch {
|
|
44
44
|
this.stop(token);
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
};
|
|
47
|
+
const timer = setInterval(safeTick, this.#intervalMs);
|
|
47
48
|
timer.unref?.();
|
|
48
49
|
this.#timers.set(token, timer);
|
|
50
|
+
safeTick();
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
package/src/resume-scheduler.js
CHANGED
|
@@ -119,12 +119,10 @@ export class ResumeScheduler {
|
|
|
119
119
|
opened_at: openedAt,
|
|
120
120
|
history_index_at_open: ctx.history.length,
|
|
121
121
|
};
|
|
122
|
-
if (trigger.kind === "elapsed"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.#elapsed.schedule(correlationId, dueAt);
|
|
127
|
-
}
|
|
122
|
+
if (trigger.kind === "elapsed" && typeof trigger.elapsed === "string") {
|
|
123
|
+
const dueAt = openedAt + parseIsoDuration(trigger.elapsed);
|
|
124
|
+
ctx.open_rfcs[correlationId].due_at = dueAt;
|
|
125
|
+
this.#elapsed.schedule(correlationId, dueAt);
|
|
128
126
|
}
|
|
129
127
|
}
|
|
130
128
|
|
|
@@ -204,7 +202,7 @@ export class ResumeScheduler {
|
|
|
204
202
|
const trigger = rfc.trigger;
|
|
205
203
|
if (!trigger) continue;
|
|
206
204
|
const observed = {
|
|
207
|
-
|
|
205
|
+
replies: ctx.history.length - rfc.history_index_at_open,
|
|
208
206
|
opened_at: rfc.opened_at,
|
|
209
207
|
};
|
|
210
208
|
if (evaluateTrigger(trigger, observed, Date.now()).fired) {
|
package/src/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
2
3
|
import { serve } from "@hono/node-server";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -44,6 +45,17 @@ export function createBridgeServer({
|
|
|
44
45
|
|
|
45
46
|
const app = new Hono();
|
|
46
47
|
|
|
48
|
+
// Security headers — standard hardening for a backend service.
|
|
49
|
+
app.use("*", async (c, next) => {
|
|
50
|
+
await next();
|
|
51
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
52
|
+
c.header("X-Frame-Options", "DENY");
|
|
53
|
+
c.header("Cache-Control", "no-store");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Request body size limit — 1 MB is generous for JSON callback payloads.
|
|
57
|
+
app.use("*", bodyLimit({ maxSize: 1024 * 1024 }));
|
|
58
|
+
|
|
47
59
|
// Capture the raw POST body once, before downstream handlers parse it.
|
|
48
60
|
// Channel adapters use this buffer to verify HMAC signatures.
|
|
49
61
|
app.use("*", async (c, next) => {
|
package/src/triggers.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {object} ResumeTrigger
|
|
3
|
-
* @property {"
|
|
4
|
-
* @property {number} [
|
|
5
|
-
*
|
|
6
|
-
* @property {string} [elapsed] -
|
|
7
|
-
* `"
|
|
3
|
+
* @property {"missing_input"|"escalation_needed"|"elapsed"} kind
|
|
4
|
+
* @property {number} [replies] - Required for `kind: "missing_input"`.
|
|
5
|
+
* Number of new replies on the dispatching thread needed to fire.
|
|
6
|
+
* @property {string} [elapsed] - Required for `kind: "elapsed"`.
|
|
7
|
+
* ISO-8601 duration, e.g. `"P14D"`, `"PT12H"`, `"P1DT6H"`.
|
|
8
|
+
* @property {string} [signal] - Required for `kind: "escalation_needed"`.
|
|
9
|
+
* Reserved for future use. The bridge throws when evaluating this kind
|
|
10
|
+
* until signal-based resume support lands.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
const ISO_8601_DURATION =
|
|
@@ -42,7 +45,7 @@ export function parseIsoDuration(duration) {
|
|
|
42
45
|
* Evaluate whether a resume trigger has fired.
|
|
43
46
|
*
|
|
44
47
|
* @param {ResumeTrigger} trigger
|
|
45
|
-
* @param {{
|
|
48
|
+
* @param {{replies?: number, opened_at?: number}} observed
|
|
46
49
|
* @param {number} now - ms epoch (caller-provided for testability)
|
|
47
50
|
* @returns {{fired: boolean, due_at?: number}}
|
|
48
51
|
*/
|
|
@@ -56,37 +59,27 @@ export function evaluateTrigger(trigger, observed, now) {
|
|
|
56
59
|
observed ??= {};
|
|
57
60
|
|
|
58
61
|
switch (trigger.kind) {
|
|
59
|
-
case "
|
|
60
|
-
return
|
|
62
|
+
case "missing_input":
|
|
63
|
+
return evaluateMissingInput(trigger, observed);
|
|
61
64
|
case "elapsed":
|
|
62
65
|
return evaluateElapsed(trigger, observed, now);
|
|
63
|
-
case "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: { fired: false };
|
|
68
|
-
const e =
|
|
69
|
-
trigger.elapsed !== undefined
|
|
70
|
-
? evaluateElapsed(trigger, observed, now)
|
|
71
|
-
: { fired: false };
|
|
72
|
-
if (r.fired || e.fired) return { fired: true };
|
|
73
|
-
return e.due_at !== undefined
|
|
74
|
-
? { fired: false, due_at: e.due_at }
|
|
75
|
-
: { fired: false };
|
|
76
|
-
}
|
|
66
|
+
case "escalation_needed":
|
|
67
|
+
throw new Error(
|
|
68
|
+
"escalation_needed is reserved for future use. See the follow-up spec for signal-based resume.",
|
|
69
|
+
);
|
|
77
70
|
default:
|
|
78
71
|
throw new Error(`Unsupported trigger kind: ${trigger.kind}`);
|
|
79
72
|
}
|
|
80
73
|
}
|
|
81
74
|
|
|
82
|
-
function
|
|
83
|
-
if (typeof trigger.
|
|
75
|
+
function evaluateMissingInput(trigger, observed) {
|
|
76
|
+
if (typeof trigger.replies !== "number" || trigger.replies < 1) {
|
|
84
77
|
throw new Error(
|
|
85
|
-
'trigger.
|
|
78
|
+
'trigger.replies must be a positive number for kind "missing_input"',
|
|
86
79
|
);
|
|
87
80
|
}
|
|
88
|
-
const seen = observed.
|
|
89
|
-
return { fired: seen >= trigger.
|
|
81
|
+
const seen = observed.replies ?? 0;
|
|
82
|
+
return { fired: seen >= trigger.replies };
|
|
90
83
|
}
|
|
91
84
|
|
|
92
85
|
function evaluateElapsed(trigger, observed, now) {
|