@forwardimpact/libbridge 0.1.9 → 0.1.11
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 -2
- package/src/callback-handler.js +11 -4
- package/src/callback-payload.js +2 -3
- package/src/callback-registry.js +2 -3
- package/src/dispatcher.js +29 -9
- package/src/elapsed-scheduler.js +2 -3
- package/src/ghserver-token-resolver.js +62 -0
- package/src/inbox-handler.js +12 -2
- package/src/index.js +3 -5
- package/src/link-resume.js +100 -9
- package/src/rate-limit.js +2 -7
- package/src/resume-scheduler.js +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"@forwardimpact/libutil": "^0.1.84"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@forwardimpact/libmock": "^0.1.0"
|
|
48
|
+
"@forwardimpact/libmock": "^0.1.0",
|
|
49
|
+
"@forwardimpact/libtelemetry": "^0.1.0"
|
|
49
50
|
},
|
|
50
51
|
"engines": {
|
|
51
52
|
"bun": ">=1.2.0",
|
package/src/callback-handler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
import { validateCallbackPayload } from "./callback-payload.js";
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -60,8 +58,9 @@ export function createCallbackHandler({
|
|
|
60
58
|
loadDiscussionId,
|
|
61
59
|
ackFinishTarget,
|
|
62
60
|
handleReply,
|
|
63
|
-
clock
|
|
61
|
+
clock,
|
|
64
62
|
}) {
|
|
63
|
+
if (!clock) throw new Error("clock is required");
|
|
65
64
|
if (!channel) throw new Error("channel is required");
|
|
66
65
|
if (!callbacks) throw new Error("callbacks is required");
|
|
67
66
|
if (!ack) throw new Error("ack is required");
|
|
@@ -112,7 +111,15 @@ export function createCallbackHandler({
|
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
const discussionId = loadDiscussionId(meta);
|
|
115
|
-
|
|
114
|
+
// The dispatcher bound the resolved tenant on the callback token's domain
|
|
115
|
+
// meta (`default` in single-tenant). Thread it into the load so
|
|
116
|
+
// multi-tenant lookups hit the tenant-scoped key rather than re-resolving
|
|
117
|
+
// by channel (which the registry cannot do — the channel is not a key).
|
|
118
|
+
const ctx = await store.loadByChannel(
|
|
119
|
+
channel,
|
|
120
|
+
discussionId,
|
|
121
|
+
meta.meta?.tenant_id,
|
|
122
|
+
);
|
|
116
123
|
if (!ctx) {
|
|
117
124
|
logger.error?.("callback", "context missing", {
|
|
118
125
|
discussion_id: discussionId,
|
package/src/callback-payload.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
export const MAX_FIELD_LENGTH = 2000;
|
|
4
2
|
export const MAX_REPLY_COUNT = 50;
|
|
5
3
|
|
|
@@ -129,8 +127,9 @@ export function newDiscussionContext({
|
|
|
129
127
|
channel,
|
|
130
128
|
discussionId,
|
|
131
129
|
participant,
|
|
132
|
-
clock
|
|
130
|
+
clock,
|
|
133
131
|
}) {
|
|
132
|
+
if (!clock) throw new Error("clock is required");
|
|
134
133
|
return {
|
|
135
134
|
id: `${channel}:${discussionId}`,
|
|
136
135
|
channel,
|
package/src/callback-registry.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
-
|
|
5
3
|
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
6
4
|
|
|
7
5
|
/**
|
|
@@ -25,7 +23,8 @@ export class CallbackRegistry {
|
|
|
25
23
|
* @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
|
|
26
24
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
27
25
|
*/
|
|
28
|
-
constructor({ ttlMs = DEFAULT_TTL_MS, clock
|
|
26
|
+
constructor({ ttlMs = DEFAULT_TTL_MS, clock } = {}) {
|
|
27
|
+
if (!clock) throw new Error("clock is required");
|
|
29
28
|
this.#ttlMs = ttlMs;
|
|
30
29
|
this.#clock = clock;
|
|
31
30
|
}
|
package/src/dispatcher.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
4
|
-
|
|
5
3
|
import { dispatchWorkflow } from "./dispatch.js";
|
|
6
4
|
|
|
7
5
|
/** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
|
|
@@ -37,7 +35,7 @@ export class Dispatcher {
|
|
|
37
35
|
githubRepo,
|
|
38
36
|
tokenResolver,
|
|
39
37
|
tenantResolver,
|
|
40
|
-
clock
|
|
38
|
+
clock,
|
|
41
39
|
}) {
|
|
42
40
|
if (!callbacks) throw new Error("callbacks is required");
|
|
43
41
|
if (!ack) throw new Error("ack is required");
|
|
@@ -46,9 +44,15 @@ export class Dispatcher {
|
|
|
46
44
|
throw new Error("callbackBaseUrl is required");
|
|
47
45
|
}
|
|
48
46
|
if (!workflowFile) throw new Error("workflowFile is required");
|
|
49
|
-
|
|
47
|
+
// A non-empty static repo is required in single-tenant mode; multi-tenant
|
|
48
|
+
// mode derives the repo per request from the resolved tenant, so an empty
|
|
49
|
+
// string is accepted (the dispatch falls back to `tenant.repo`).
|
|
50
|
+
if (typeof githubRepo !== "string") {
|
|
51
|
+
throw new Error("githubRepo is required");
|
|
52
|
+
}
|
|
50
53
|
if (!tokenResolver) throw new Error("tokenResolver is required");
|
|
51
54
|
if (!tenantResolver) throw new Error("tenantResolver is required");
|
|
55
|
+
if (!clock) throw new Error("clock is required");
|
|
52
56
|
this.#callbacks = callbacks;
|
|
53
57
|
this.#ack = ack;
|
|
54
58
|
this.#store = store;
|
|
@@ -82,9 +86,11 @@ export class Dispatcher {
|
|
|
82
86
|
if (typeof prompt !== "string") throw new Error("prompt is required");
|
|
83
87
|
if (typeof requester !== "string") throw new Error("requester is required");
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
// Resolve the tenant before the dispatch credential. The hosted dispatch
|
|
90
|
+
// identity is a repo-scoped App installation token (see design § Hosted
|
|
91
|
+
// dispatch identity), so a token resolver that mints per repo needs the
|
|
92
|
+
// resolved tenant's `repo`. The third argument is additive — the
|
|
93
|
+
// self-hosted `TokenResolver` (per-user OAuth) ignores it.
|
|
88
94
|
const tenant = await this.#tenantResolver.resolve({
|
|
89
95
|
channel: ctx.channel,
|
|
90
96
|
key: ctx.channel_tenant_key,
|
|
@@ -94,19 +100,33 @@ export class Dispatcher {
|
|
|
94
100
|
}
|
|
95
101
|
const tenant_id = tenant.tenant_id;
|
|
96
102
|
|
|
103
|
+
const auth = await this.#tokenResolver.resolve(
|
|
104
|
+
ctx.channel,
|
|
105
|
+
requester,
|
|
106
|
+
tenant,
|
|
107
|
+
);
|
|
108
|
+
if (auth.kind !== "token") return auth;
|
|
109
|
+
|
|
97
110
|
const correlationId = randomUUID();
|
|
98
111
|
const mergedMeta = { ...(callbackMeta ?? {}), requester, tenant_id };
|
|
99
112
|
const token = this.#callbacks.register(correlationId, mergedMeta);
|
|
100
113
|
ctx.pending_callbacks[token] = correlationId;
|
|
101
114
|
ctx.active_requester = requester;
|
|
102
115
|
const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${tenant_id}/${token}`;
|
|
103
|
-
const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
|
|
116
|
+
const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}?tenant_id=${encodeURIComponent(tenant_id)}`;
|
|
117
|
+
|
|
118
|
+
// The workflow_dispatch targets the resolved tenant's repository when the
|
|
119
|
+
// resolver supplies one (multi-tenant); otherwise the static configured
|
|
120
|
+
// repo (single-tenant). Both modes fire against the customer's runner.
|
|
121
|
+
const dispatchRepo = tenant.repo
|
|
122
|
+
? `${tenant.repo.owner}/${tenant.repo.name}`
|
|
123
|
+
: this.#githubRepo;
|
|
104
124
|
|
|
105
125
|
if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
|
|
106
126
|
try {
|
|
107
127
|
await dispatchWorkflow({
|
|
108
128
|
workflowFile: this.#workflowFile,
|
|
109
|
-
repo:
|
|
129
|
+
repo: dispatchRepo,
|
|
110
130
|
token: auth.token,
|
|
111
131
|
prompt,
|
|
112
132
|
callbackUrl,
|
package/src/elapsed-scheduler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
const CHUNK_CAP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
2
|
|
|
5
3
|
/**
|
|
@@ -22,8 +20,9 @@ export class ElapsedScheduler {
|
|
|
22
20
|
* @param {(err: Error, correlationId: string) => void} [options.onError] - Invoked when `onFire` rejects.
|
|
23
21
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
24
22
|
*/
|
|
25
|
-
constructor({ onFire, onError = () => {}, clock
|
|
23
|
+
constructor({ onFire, onError = () => {}, clock }) {
|
|
26
24
|
if (typeof onFire !== "function") throw new Error("onFire is required");
|
|
25
|
+
if (!clock) throw new Error("clock is required");
|
|
27
26
|
this.#onFire = onFire;
|
|
28
27
|
this.#onError = onError;
|
|
29
28
|
this.#clock = clock;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted dispatch identity for multi-tenant bridges. The `workflow_dispatch`
|
|
3
|
+
* credential is a repo-scoped GitHub App installation token minted by
|
|
4
|
+
* `services/ghserver` for the resolved tenant's repository — not the per-user
|
|
5
|
+
* OAuth token used by the self-hosted path (see design § Hosted dispatch
|
|
6
|
+
* identity). Both `services/ghbridge` and `services/msbridge` share this
|
|
7
|
+
* resolver in multi-tenant mode; the channel-specific reply credential (Bot
|
|
8
|
+
* Framework for Teams, App installation for GitHub) is unaffected.
|
|
9
|
+
*
|
|
10
|
+
* This resolver satisfies the same duck-typed surface the `Dispatcher`
|
|
11
|
+
* expects from a `TokenResolver`: `resolve(surface, requester, tenant) ->
|
|
12
|
+
* DispatchAuth`. The `tenant` argument carries the registry row (with its
|
|
13
|
+
* `repo`) the dispatcher resolved before requesting the credential, so the
|
|
14
|
+
* mint is scoped to exactly that repository — there is no per-user link step.
|
|
15
|
+
*
|
|
16
|
+
* Lives in libbridge (not a channel adapter) because it imports no channel
|
|
17
|
+
* SDK: it depends only on the duck-typed ghserver client, keeping libbridge's
|
|
18
|
+
* "no channel SDKs" invariant intact.
|
|
19
|
+
*/
|
|
20
|
+
export class GhServerTokenResolver {
|
|
21
|
+
#client;
|
|
22
|
+
#requestedBy;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} client - ghserver gRPC client exposing
|
|
26
|
+
* `MintInstallationToken({owner, name, requested_by})`.
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {string} [options.requestedBy] - Audit tag forwarded as
|
|
29
|
+
* `requested_by` on the mint; identifies the calling bridge.
|
|
30
|
+
*/
|
|
31
|
+
constructor(client, { requestedBy = "bridge" } = {}) {
|
|
32
|
+
if (!client) throw new Error("ghserver client is required");
|
|
33
|
+
this.#client = client;
|
|
34
|
+
this.#requestedBy = requestedBy;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} _surface - Unused; the App installation is repo-scoped.
|
|
39
|
+
* @param {string} _requester - Unused; the App authors the dispatch.
|
|
40
|
+
* @param {import("./tenant-resolver.js").Tenant} [tenant]
|
|
41
|
+
* @returns {Promise<{kind: string, token?: string, error?: Error}>}
|
|
42
|
+
*/
|
|
43
|
+
async resolve(_surface, _requester, tenant) {
|
|
44
|
+
const repo = tenant?.repo;
|
|
45
|
+
if (!repo?.owner || !repo?.name) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "transient",
|
|
48
|
+
error: new Error("tenant_repo_unresolved"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const { installation_token } = await this.#client.MintInstallationToken({
|
|
53
|
+
owner: repo.owner,
|
|
54
|
+
name: repo.name,
|
|
55
|
+
requested_by: this.#requestedBy,
|
|
56
|
+
});
|
|
57
|
+
return { kind: "token", token: installation_token };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { kind: "transient", error: err };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/inbox-handler.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { bridge } from "@forwardimpact/libtype";
|
|
2
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Long-poll handler for the per-correlation inbox. The run's InboxPoller
|
|
@@ -18,11 +17,21 @@ export function createInboxHandler({
|
|
|
18
17
|
logger,
|
|
19
18
|
pollTimeoutMs = 30_000,
|
|
20
19
|
pollIntervalMs = 1_000,
|
|
21
|
-
clock
|
|
20
|
+
clock,
|
|
22
21
|
}) {
|
|
22
|
+
if (!clock) throw new Error("clock is required");
|
|
23
23
|
return async (c) => {
|
|
24
24
|
const correlationId = c.req.param("correlationId");
|
|
25
25
|
const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
|
|
26
|
+
// The dispatcher embeds the resolved tenant on the inbox URL it hands the
|
|
27
|
+
// run (single-tenant binds the literal `default`). `services/bridge`
|
|
28
|
+
// rejects a `DrainInbox` without a `tenant_id`, so a poll that omits it is
|
|
29
|
+
// a caller error: fail fast rather than leaking a cross-tenant queue.
|
|
30
|
+
const tenant_id = c.req.query("tenant_id");
|
|
31
|
+
if (!tenant_id) {
|
|
32
|
+
logger.error?.("inbox", "missing tenant_id");
|
|
33
|
+
return c.json({ error: "tenant_id required" }, 400);
|
|
34
|
+
}
|
|
26
35
|
const deadline = clock.now() + pollTimeoutMs;
|
|
27
36
|
|
|
28
37
|
while (clock.now() < deadline) {
|
|
@@ -31,6 +40,7 @@ export function createInboxHandler({
|
|
|
31
40
|
bridge.DrainInboxRequest.fromObject({
|
|
32
41
|
correlation_id: correlationId,
|
|
33
42
|
since_seq: sinceSeq,
|
|
43
|
+
tenant_id,
|
|
34
44
|
}),
|
|
35
45
|
);
|
|
36
46
|
if (result.messages?.length > 0) {
|
package/src/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @property {() => Promise<void>} flush
|
|
20
20
|
* @property {() => Promise<void>} shutdown
|
|
21
21
|
* @property {(target: object) => Promise<void>} [putPendingDispatch]
|
|
22
|
-
* @property {(linkToken: string) => Promise<object|null>} [resolvePendingDispatch]
|
|
22
|
+
* @property {(linkToken: string, expectedSurfaceUserId?: string) => Promise<object|null>} [resolvePendingDispatch]
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
export { createBridgeServer } from "./server.js";
|
|
@@ -29,16 +29,14 @@ export { appendHistory } from "./history.js";
|
|
|
29
29
|
export { RateLimiter } from "./rate-limit.js";
|
|
30
30
|
export { dispatchWorkflow } from "./dispatch.js";
|
|
31
31
|
export { ProgressTicker } from "./progress-ticker.js";
|
|
32
|
-
export {
|
|
33
|
-
Acknowledgement,
|
|
34
|
-
DEFAULT_TYPING_VERBS,
|
|
35
|
-
} from "./acknowledgement.js";
|
|
32
|
+
export { Acknowledgement, DEFAULT_TYPING_VERBS } from "./acknowledgement.js";
|
|
36
33
|
export { Dispatcher } from "./dispatcher.js";
|
|
37
34
|
export {
|
|
38
35
|
DefaultTenantResolver,
|
|
39
36
|
RegistryTenantResolver,
|
|
40
37
|
} from "./tenant-resolver.js";
|
|
41
38
|
export { TokenResolver } from "./token-resolver.js";
|
|
39
|
+
export { GhServerTokenResolver } from "./ghserver-token-resolver.js";
|
|
42
40
|
export {
|
|
43
41
|
CallbackHandlerError,
|
|
44
42
|
createCallbackHandler,
|
package/src/link-resume.js
CHANGED
|
@@ -1,25 +1,81 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isTrusted } from "@forwardimpact/libutil/trusted-origins";
|
|
3
|
+
import { verifyCompletionTicket } from "@forwardimpact/libutil/completion-ticket";
|
|
2
4
|
import { normalizeBaseUrl } from "./callback-payload.js";
|
|
3
5
|
import { buildPrompt } from "./prompt.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Prepare a link-resume URL for the IdP authorize step.
|
|
9
|
+
*
|
|
10
|
+
* Discriminated return so a missing `catch` cannot become a 5xx oracle in
|
|
11
|
+
* the caller. The keyword-arg shape makes "forgot to pass trustedOrigins"
|
|
12
|
+
* a loud boot-time `TypeError` for any future xbridge.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} args
|
|
15
|
+
* @param {string} args.authorizeUrl Upstream IdP authorize URL the bridge
|
|
16
|
+
* intends to post into the channel.
|
|
17
|
+
* @param {string} args.callbackBaseUrl Bridge's own callback base URL; the
|
|
18
|
+
* per-bridge `/api/link-complete` is composed from this.
|
|
19
|
+
* @param {Set<string>} args.trustedOrigins Trusted-origin set produced by
|
|
20
|
+
* `loadTrustedIdpOrigins`. Required — a missing or non-Set value throws.
|
|
21
|
+
* @returns {{linkToken: string, augmentedUrl: string} | {skipped: true, reason: string}}
|
|
22
|
+
* On a trusted, parseable URL: `{ linkToken, augmentedUrl }`. On any
|
|
23
|
+
* refusal: `{ skipped: true, reason: "untrusted_origin" }`.
|
|
9
24
|
*/
|
|
10
|
-
export function prepareLinkResume(
|
|
25
|
+
export function prepareLinkResume({
|
|
26
|
+
authorizeUrl,
|
|
27
|
+
callbackBaseUrl,
|
|
28
|
+
trustedOrigins,
|
|
29
|
+
}) {
|
|
30
|
+
if (!(trustedOrigins instanceof Set))
|
|
31
|
+
throw new TypeError("prepareLinkResume: trustedOrigins must be a Set");
|
|
32
|
+
let originUrl;
|
|
33
|
+
try {
|
|
34
|
+
originUrl = new URL(authorizeUrl);
|
|
35
|
+
} catch {
|
|
36
|
+
return { skipped: true, reason: "untrusted_origin" };
|
|
37
|
+
}
|
|
38
|
+
if (!isTrusted(originUrl.origin, trustedOrigins))
|
|
39
|
+
return { skipped: true, reason: "untrusted_origin" };
|
|
40
|
+
|
|
11
41
|
const linkToken = randomUUID();
|
|
12
|
-
|
|
13
|
-
url.searchParams.set(
|
|
42
|
+
originUrl.searchParams.set(
|
|
14
43
|
"redirect_uri",
|
|
15
44
|
`${normalizeBaseUrl(callbackBaseUrl)}/api/link-complete`,
|
|
16
45
|
);
|
|
17
|
-
|
|
18
|
-
return { linkToken, augmentedUrl:
|
|
46
|
+
originUrl.searchParams.set("client_state", linkToken);
|
|
47
|
+
return { linkToken, augmentedUrl: originUrl.toString() };
|
|
19
48
|
}
|
|
20
49
|
|
|
50
|
+
const UNABLE_TO_VERIFY_HTML =
|
|
51
|
+
"<!DOCTYPE html><html><body><h1>Unable to verify completion</h1>" +
|
|
52
|
+
"<p>The completion request could not be verified. Please try " +
|
|
53
|
+
"linking again from the conversation.</p></body></html>";
|
|
54
|
+
|
|
21
55
|
/**
|
|
56
|
+
* Factory for the `/api/link-complete` GET handler.
|
|
57
|
+
*
|
|
58
|
+
* Handler ordering: the ticket is verified **before** any store touch —
|
|
59
|
+
* an attacker without a valid ticket exits at the verify step and never
|
|
60
|
+
* sees a present-vs-absent timing oracle on `linkToken`.
|
|
61
|
+
*
|
|
62
|
+
* The `surface_user_id` cross-check is performed **server-side** by passing
|
|
63
|
+
* `verify.claims.surfaceUserId` as `expectedSurfaceUserId` to
|
|
64
|
+
* `store.resolvePendingDispatch`. The bridge refuses to consume the entry
|
|
65
|
+
* on mismatch — so an attacker who minted a valid ticket against the
|
|
66
|
+
* victim's `link_token` (e.g. by driving the IdP round-trip under their
|
|
67
|
+
* own account with `client_state=victim_link_token`) cannot drain the
|
|
68
|
+
* auto-resume affordance: the bridge returns `{ unattributable: true }`
|
|
69
|
+
* and the entry stays available for the legitimate user.
|
|
70
|
+
*
|
|
22
71
|
* @param {object} options
|
|
72
|
+
* @param {string} options.channel Channel id (e.g. `"github-discussions"`).
|
|
73
|
+
* @param {object} options.store
|
|
74
|
+
* @param {object} options.dispatcher
|
|
75
|
+
* @param {(ctx: object) => object} options.buildCallbackMeta
|
|
76
|
+
* @param {Set<string>} options.trustedOrigins Required.
|
|
77
|
+
* @param {string} options.ticketSecret Required.
|
|
78
|
+
* @param {{now: () => number}} options.clock Required.
|
|
23
79
|
* @returns {(c: import("hono").Context) => Promise<Response>}
|
|
24
80
|
*/
|
|
25
81
|
export function createLinkCompleteHandler({
|
|
@@ -27,7 +83,21 @@ export function createLinkCompleteHandler({
|
|
|
27
83
|
store,
|
|
28
84
|
dispatcher,
|
|
29
85
|
buildCallbackMeta,
|
|
86
|
+
trustedOrigins,
|
|
87
|
+
ticketSecret,
|
|
88
|
+
clock,
|
|
30
89
|
}) {
|
|
90
|
+
if (!(trustedOrigins instanceof Set))
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
"createLinkCompleteHandler: trustedOrigins must be a Set",
|
|
93
|
+
);
|
|
94
|
+
if (typeof ticketSecret !== "string" || ticketSecret.length === 0)
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
"createLinkCompleteHandler: ticketSecret must be a non-empty string",
|
|
97
|
+
);
|
|
98
|
+
if (!clock || typeof clock.now !== "function")
|
|
99
|
+
throw new TypeError("createLinkCompleteHandler: clock is required");
|
|
100
|
+
|
|
31
101
|
return async (c) => {
|
|
32
102
|
const linkToken = c.req.query("state");
|
|
33
103
|
if (!linkToken) {
|
|
@@ -38,7 +108,22 @@ export function createLinkCompleteHandler({
|
|
|
38
108
|
);
|
|
39
109
|
}
|
|
40
110
|
|
|
41
|
-
const
|
|
111
|
+
const ticket = c.req.query("ticket");
|
|
112
|
+
const verify = verifyCompletionTicket({
|
|
113
|
+
ticket,
|
|
114
|
+
expected: { linkToken },
|
|
115
|
+
trustedOrigins,
|
|
116
|
+
secret: ticketSecret,
|
|
117
|
+
now: clock.now(),
|
|
118
|
+
});
|
|
119
|
+
if (!verify.ok) {
|
|
120
|
+
return c.html(UNABLE_TO_VERIFY_HTML);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const target = await store.resolvePendingDispatch(
|
|
124
|
+
linkToken,
|
|
125
|
+
verify.claims.surfaceUserId,
|
|
126
|
+
);
|
|
42
127
|
if (!target) {
|
|
43
128
|
return c.html(
|
|
44
129
|
"<!DOCTYPE html><html><body><h1>Already processed</h1>" +
|
|
@@ -46,6 +131,12 @@ export function createLinkCompleteHandler({
|
|
|
46
131
|
"</p></body></html>",
|
|
47
132
|
);
|
|
48
133
|
}
|
|
134
|
+
if (target.unattributable) {
|
|
135
|
+
// Bridge refused to consume because the ticket's surfaceUserId does
|
|
136
|
+
// not match the pending row. The pending entry is left intact for
|
|
137
|
+
// the legitimate user.
|
|
138
|
+
return c.html(UNABLE_TO_VERIFY_HTML);
|
|
139
|
+
}
|
|
49
140
|
|
|
50
141
|
const ctx = await store.loadByChannel(channel, target.discussion_id);
|
|
51
142
|
if (!ctx) {
|
package/src/rate-limit.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Sliding-window rate limiter. Operates on a caller-owned `dispatches: number[]`
|
|
5
3
|
* array of timestamps and returns a structured result so callers can both
|
|
@@ -16,11 +14,8 @@ export class RateLimiter {
|
|
|
16
14
|
* @param {number} [options.max] - Max dispatches allowed in the window (default: 5)
|
|
17
15
|
* @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
|
|
18
16
|
*/
|
|
19
|
-
constructor({
|
|
20
|
-
|
|
21
|
-
max = 5,
|
|
22
|
-
clock = createDefaultClock(),
|
|
23
|
-
} = {}) {
|
|
17
|
+
constructor({ windowMs = 60_000, max = 5, clock } = {}) {
|
|
18
|
+
if (!clock) throw new Error("clock is required");
|
|
24
19
|
this.#windowMs = windowMs;
|
|
25
20
|
this.#max = max;
|
|
26
21
|
this.#clock = clock;
|
package/src/resume-scheduler.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createDefaultClock } from "@forwardimpact/libutil/runtime";
|
|
2
|
-
|
|
3
1
|
import { ElapsedScheduler } from "./elapsed-scheduler.js";
|
|
4
2
|
import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
5
3
|
|
|
@@ -36,7 +34,7 @@ export class ResumeScheduler {
|
|
|
36
34
|
buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
37
35
|
buildResumeInputs = () => ({}),
|
|
38
36
|
onDeclined = null,
|
|
39
|
-
clock
|
|
37
|
+
clock,
|
|
40
38
|
}) {
|
|
41
39
|
if (!dispatcher) throw new Error("dispatcher is required");
|
|
42
40
|
if (!store) throw new Error("store is required");
|
|
@@ -49,6 +47,7 @@ export class ResumeScheduler {
|
|
|
49
47
|
if (onDeclined != null && typeof onDeclined !== "function") {
|
|
50
48
|
throw new Error("onDeclined must be a function");
|
|
51
49
|
}
|
|
50
|
+
if (!clock) throw new Error("clock is required");
|
|
52
51
|
this.#dispatcher = dispatcher;
|
|
53
52
|
this.#store = store;
|
|
54
53
|
this.#logger = logger ?? null;
|