@autono/pinbox-core 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connectors/index.d.ts +57 -1
- package/dist/connectors/index.js +3 -103
- package/dist/do.d.ts +25 -1
- package/dist/do.js +56 -7
- package/dist/{hub-I67BuX9S.js → hub-D-cHpKmA.js} +1 -1
- package/dist/hub-server.js +1 -1
- package/dist/hub.js +1 -1
- package/dist/{poll-BrcAuaAz.js → poll-ZSv3wN9C.js} +1 -1
- package/dist/slack-C4P0AbuZ.js +455 -0
- package/package.json +1 -1
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
import { g as ThreadMessage } from "../schema-BlM4lTLW.js";
|
|
2
2
|
import { a as RemoteStatus, i as RemoteComment, n as ConnectorEvents, r as ConnectorTransport, t as Connector } from "../types-BmfT_m1p.js";
|
|
3
3
|
import { c as PinStore } from "../store-DAB5CEA1.js";
|
|
4
|
+
//#region src/connectors/github-app.d.ts
|
|
5
|
+
/** The slice of fetch this transport uses — injectable without dragging in Bun's extras. */
|
|
6
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
type GithubAppTransportOptions = {
|
|
8
|
+
/** The App's numeric id (the `iss` claim). */
|
|
9
|
+
appId: string;
|
|
10
|
+
/** PEM, PKCS#1 or PKCS#8 — GitHub downloads PKCS#1. */
|
|
11
|
+
privateKeyPem: string;
|
|
12
|
+
/** The installation on the target repo's owner. */
|
|
13
|
+
installationId: string;
|
|
14
|
+
/** "owner/name". */
|
|
15
|
+
repo: string;
|
|
16
|
+
/** Default https://api.github.com; GHES uses https://<host>/api/v3. */
|
|
17
|
+
apiBase?: string;
|
|
18
|
+
fetchImpl?: FetchLike;
|
|
19
|
+
/** Test seam: epoch ms. */
|
|
20
|
+
now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
/** A transport failure carrying the hint the link route surfaces as E_CONNECTOR. */
|
|
23
|
+
declare class GithubAppError extends Error {
|
|
24
|
+
readonly hint: string | undefined;
|
|
25
|
+
readonly status: number;
|
|
26
|
+
constructor(message: string, status: number, hint?: string);
|
|
27
|
+
}
|
|
28
|
+
/** The App's own credential: a short-lived RS256 JWT with `iss` = app id. */
|
|
29
|
+
declare function signAppJwt(appId: string, privateKeyPem: string, now?: () => number): Promise<string>;
|
|
30
|
+
/** GitHub's standard request headers for the REST API. */
|
|
31
|
+
declare function githubHeaders(bearer: string, json?: boolean): Record<string, string>;
|
|
32
|
+
type InstallationToken = {
|
|
33
|
+
token: string;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
};
|
|
36
|
+
/** Exchange an App JWT for a one-hour installation token. */
|
|
37
|
+
declare function mintInstallationToken(api: string, installationId: string, appJwt: string, fetchImpl: FetchLike): Promise<InstallationToken>;
|
|
38
|
+
declare function createGithubAppTransport(opts: GithubAppTransportOptions): ConnectorTransport;
|
|
39
|
+
/** PKCS#8 PEM in, PKCS#8 PEM out; PKCS#1 PEM (GitHub's download) is wrapped into PKCS#8. */
|
|
40
|
+
declare function toPkcs8Pem(pem: string): string;
|
|
41
|
+
/** PrivateKeyInfo ::= SEQUENCE { version 0, algorithm rsaEncryption, privateKey OCTET STRING }. */
|
|
42
|
+
declare function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array;
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/connectors/github-webhook.d.ts
|
|
45
|
+
type GithubWebhookOptions = {
|
|
46
|
+
/** The webhook secret configured on the App — HMAC-SHA256 key for the signature. */
|
|
47
|
+
secret: string;
|
|
48
|
+
/** "owner/name": events for any other repository are acknowledged and ignored. */
|
|
49
|
+
repo: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Handle one webhook delivery. Returns the hub's machine envelope: 200 with
|
|
53
|
+
* `{ applied, ignored }`, 401 `E_AUTH` on a signature failure, 400 `E_INVALID_INPUT` on a
|
|
54
|
+
* body GitHub would never send.
|
|
55
|
+
*/
|
|
56
|
+
declare function handleGithubWebhook(req: Request, store: PinStore, opts: GithubWebhookOptions): Promise<Response>;
|
|
57
|
+
/** `sha256=<hex hmac>` over the raw body, compared in constant time. */
|
|
58
|
+
declare function signatureValid(secret: string, rawBody: string, header: string | null): Promise<boolean>;
|
|
59
|
+
//#endregion
|
|
4
60
|
//#region src/connectors/mirror.d.ts
|
|
5
61
|
declare function createConnectorEvents(store: PinStore, pinId: string): ConnectorEvents;
|
|
6
62
|
/**
|
|
@@ -34,4 +90,4 @@ type SlackConnectorOptions = {
|
|
|
34
90
|
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
35
91
|
declare function createSlackConnector(transport: ConnectorTransport, opts: SlackConnectorOptions): Connector;
|
|
36
92
|
//#endregion
|
|
37
|
-
export { Connector, ConnectorEvents, ConnectorTransport, POLL_OPEN_MS, POLL_RESOLVED_MS, RemoteComment, RemoteStatus, SlackConnectorOptions, SlackTransportOptions, createConnectorEvents, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates };
|
|
93
|
+
export { Connector, ConnectorEvents, ConnectorTransport, FetchLike, GithubAppError, GithubAppTransportOptions, GithubWebhookOptions, InstallationToken, POLL_OPEN_MS, POLL_RESOLVED_MS, RemoteComment, RemoteStatus, SlackConnectorOptions, SlackTransportOptions, createConnectorEvents, createGithubAppTransport, createSlackConnector, createSlackTransport, drainConnectorPolls, githubHeaders, handleGithubWebhook, mintInstallationToken, outboundCandidates, pkcs1ToPkcs8, signAppJwt, signatureValid, toPkcs8Pem };
|
package/dist/connectors/index.js
CHANGED
|
@@ -1,103 +1,3 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
//#region src/connectors/slack.ts
|
|
5
|
-
/**
|
|
6
|
-
* request(op, params) → POST https://slack.com/api/<op> (JSON, bearer botToken).
|
|
7
|
-
* Slack's `{ok:false, error}` becomes a rejection carrying the Slack error string —
|
|
8
|
-
* the route layer surfaces it as 502 E_CONNECTOR.
|
|
9
|
-
*/
|
|
10
|
-
function createSlackTransport(opts) {
|
|
11
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
12
|
-
return { async request(op, params) {
|
|
13
|
-
const res = await fetchImpl(`https://slack.com/api/${op}`, {
|
|
14
|
-
method: "POST",
|
|
15
|
-
headers: {
|
|
16
|
-
authorization: `Bearer ${opts.botToken}`,
|
|
17
|
-
"content-type": "application/json; charset=utf-8"
|
|
18
|
-
},
|
|
19
|
-
body: JSON.stringify(params)
|
|
20
|
-
});
|
|
21
|
-
if (!res.ok) throw new Error(`slack ${op} failed: HTTP ${res.status}`);
|
|
22
|
-
const data = await res.json();
|
|
23
|
-
const envelope = SlackEnvelopeSchema.parse(data);
|
|
24
|
-
if (!envelope.ok) throw new Error(`slack ${op} failed: ${envelope.error ?? "unknown_error"}`);
|
|
25
|
-
return data;
|
|
26
|
-
} };
|
|
27
|
-
}
|
|
28
|
-
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
29
|
-
function createSlackConnector(transport, opts) {
|
|
30
|
-
return {
|
|
31
|
-
name: "slack",
|
|
32
|
-
async createItem(pin, thread) {
|
|
33
|
-
const text = [pinsToMarkdown([pin], "standard"), ...thread.map((m) => `${m.role}: ${m.text}`)].join("\n").trimEnd();
|
|
34
|
-
const posted = PostMessageSchema.parse(await transport.request("chat.postMessage", {
|
|
35
|
-
channel: opts.channel,
|
|
36
|
-
text
|
|
37
|
-
}));
|
|
38
|
-
const channel = posted.channel ?? opts.channel;
|
|
39
|
-
const permalink = PermalinkSchema.parse(await transport.request("chat.getPermalink", {
|
|
40
|
-
channel,
|
|
41
|
-
message_ts: posted.ts
|
|
42
|
-
}));
|
|
43
|
-
return {
|
|
44
|
-
connector: "slack",
|
|
45
|
-
ref: `${channel}/${posted.ts}`,
|
|
46
|
-
url: permalink.permalink
|
|
47
|
-
};
|
|
48
|
-
},
|
|
49
|
-
async postComment(link, message) {
|
|
50
|
-
const { channel, ts } = parseRef(link.ref);
|
|
51
|
-
await transport.request("chat.postMessage", {
|
|
52
|
-
channel,
|
|
53
|
-
thread_ts: ts,
|
|
54
|
-
text: message.text
|
|
55
|
-
});
|
|
56
|
-
},
|
|
57
|
-
async sync(link, events) {
|
|
58
|
-
const { channel, ts } = parseRef(link.ref);
|
|
59
|
-
const replies = RepliesSchema.parse(await transport.request("conversations.replies", {
|
|
60
|
-
channel,
|
|
61
|
-
ts
|
|
62
|
-
}));
|
|
63
|
-
for (const reply of replies.messages) {
|
|
64
|
-
if (reply.ts === ts) continue;
|
|
65
|
-
const atMs = slackTsToMs(reply.ts);
|
|
66
|
-
await events.onRemoteComment(link, {
|
|
67
|
-
origin: `slack:${reply.user}`,
|
|
68
|
-
text: reply.text ?? "",
|
|
69
|
-
at: new Date(atMs).toISOString()
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
async setRemoteStatus() {}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
const SlackEnvelopeSchema = z.looseObject({
|
|
77
|
-
ok: z.boolean(),
|
|
78
|
-
error: z.string().optional()
|
|
79
|
-
});
|
|
80
|
-
const PostMessageSchema = z.looseObject({
|
|
81
|
-
channel: z.string().optional(),
|
|
82
|
-
ts: z.string()
|
|
83
|
-
});
|
|
84
|
-
const PermalinkSchema = z.looseObject({ permalink: z.string() });
|
|
85
|
-
const RepliesSchema = z.looseObject({ messages: z.array(z.looseObject({
|
|
86
|
-
ts: z.string(),
|
|
87
|
-
user: z.string(),
|
|
88
|
-
text: z.string().optional()
|
|
89
|
-
})) });
|
|
90
|
-
function parseRef(ref) {
|
|
91
|
-
const slash = ref.indexOf("/");
|
|
92
|
-
if (slash <= 0 || slash === ref.length - 1) throw new Error(`slack link ref must be "<channel>/<thread_ts>", got "${ref}"`);
|
|
93
|
-
return {
|
|
94
|
-
channel: ref.slice(0, slash),
|
|
95
|
-
ts: ref.slice(slash + 1)
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
/** Slack ts is "<epoch-seconds>.<suffix>" — epoch milliseconds for the reported `at`. */
|
|
99
|
-
function slackTsToMs(ts) {
|
|
100
|
-
return Number(ts) * 1e3;
|
|
101
|
-
}
|
|
102
|
-
//#endregion
|
|
103
|
-
export { POLL_OPEN_MS, POLL_RESOLVED_MS, createConnectorEvents, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates };
|
|
1
|
+
import { a as createConnectorEvents, n as POLL_RESOLVED_MS, o as outboundCandidates, r as drainConnectorPolls, t as POLL_OPEN_MS } from "../poll-ZSv3wN9C.js";
|
|
2
|
+
import { a as GithubAppError, c as mintInstallationToken, d as toPkcs8Pem, i as signatureValid, l as pkcs1ToPkcs8, n as createSlackTransport, o as createGithubAppTransport, r as handleGithubWebhook, s as githubHeaders, t as createSlackConnector, u as signAppJwt } from "../slack-C4P0AbuZ.js";
|
|
3
|
+
export { GithubAppError, POLL_OPEN_MS, POLL_RESOLVED_MS, createConnectorEvents, createGithubAppTransport, createSlackConnector, createSlackTransport, drainConnectorPolls, githubHeaders, handleGithubWebhook, mintInstallationToken, outboundCandidates, pkcs1ToPkcs8, signAppJwt, signatureValid, toPkcs8Pem };
|
package/dist/do.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { a as Link, g as ThreadMessage, m as SessionRef, r as Attachment, s as Pin, t as AppliedEdit } from "./schema-BlM4lTLW.js";
|
|
2
|
+
import { t as Connector } from "./types-BmfT_m1p.js";
|
|
2
3
|
import { r as SessionStore } from "./sessions-BOrd8Yvv.js";
|
|
3
4
|
import { a as LinkStore, c as PinStore, i as DeliveryStore, l as StoredEvent, t as CursorStore } from "./store-DAB5CEA1.js";
|
|
4
5
|
import { Broadcaster } from "./ws.js";
|
|
@@ -34,6 +35,14 @@ type PinboxDoEnv = {
|
|
|
34
35
|
JWT_ISSUER?: string;
|
|
35
36
|
JWT_JWKS_URL?: string;
|
|
36
37
|
JWT_AUDIENCE?: string;
|
|
38
|
+
GITHUB_APP_ID?: string;
|
|
39
|
+
GITHUB_APP_PRIVATE_KEY?: string;
|
|
40
|
+
GITHUB_INSTALLATION_ID?: string;
|
|
41
|
+
GITHUB_REPO?: string;
|
|
42
|
+
GITHUB_API_BASE?: string;
|
|
43
|
+
GITHUB_WEBHOOK_SECRET?: string;
|
|
44
|
+
SLACK_BOT_TOKEN?: string;
|
|
45
|
+
SLACK_CHANNEL?: string;
|
|
37
46
|
MEDIA?: R2Bucket;
|
|
38
47
|
R2_ACCOUNT_ID?: string;
|
|
39
48
|
R2_BUCKET?: string;
|
|
@@ -46,6 +55,12 @@ declare class DoBroadcaster implements Broadcaster {
|
|
|
46
55
|
publish(topic: string, data: string): void;
|
|
47
56
|
subscriberCount(topic: string): number;
|
|
48
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The cloud connector set, from the environment: github when the App quartet is present,
|
|
60
|
+
* slack when its pair is. A partial quartet is ignored rather than half-configured — the
|
|
61
|
+
* link route then answers 502 E_CONNECTOR with the `pinbox doctor` hint, as with no config.
|
|
62
|
+
*/
|
|
63
|
+
declare function buildConnectors(env: PinboxDoEnv): Connector[];
|
|
49
64
|
declare class PinboxHubDO {
|
|
50
65
|
readonly store: DoPinStore;
|
|
51
66
|
readonly broadcaster: DoBroadcaster;
|
|
@@ -56,9 +71,16 @@ declare class PinboxHubDO {
|
|
|
56
71
|
private readonly handler;
|
|
57
72
|
/** Undefined when no adapter is configured — the hub then stores pins and delivers nothing. */
|
|
58
73
|
private readonly router;
|
|
74
|
+
/** Host-injected tracker connectors (github via App token, slack); empty ⇒ link routes 502. */
|
|
75
|
+
private readonly connectors;
|
|
59
76
|
constructor(ctx: DurableObjectState, env: PinboxDoEnv);
|
|
60
77
|
fetch(req: Request): Promise<Response>;
|
|
61
78
|
private intercept;
|
|
79
|
+
/**
|
|
80
|
+
* GitHub cannot present our credential: the HMAC signature is this route's whole auth
|
|
81
|
+
* (github-webhook.ts), so it sits outside the bearer/JWT gate. Unconfigured ⇒ falls to 404.
|
|
82
|
+
*/
|
|
83
|
+
private githubWebhook;
|
|
62
84
|
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void;
|
|
63
85
|
webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): void;
|
|
64
86
|
/**
|
|
@@ -69,6 +91,8 @@ declare class PinboxHubDO {
|
|
|
69
91
|
alarm(): Promise<void>;
|
|
70
92
|
/** Re-arm while work remains. Setting an alarm that already exists is a no-op. */
|
|
71
93
|
private scheduleDrain;
|
|
94
|
+
/** Any delivery row pending, or any linked pin with a poll deadline set. */
|
|
95
|
+
private hasPendingWork;
|
|
72
96
|
private upgrade;
|
|
73
97
|
private protocolError;
|
|
74
98
|
private unauthorized;
|
|
@@ -76,4 +100,4 @@ declare class PinboxHubDO {
|
|
|
76
100
|
private serveMedia;
|
|
77
101
|
}
|
|
78
102
|
//#endregion
|
|
79
|
-
export { DoBroadcaster, PinboxDoEnv, PinboxHubDO };
|
|
103
|
+
export { DoBroadcaster, PinboxDoEnv, PinboxHubDO, buildConnectors };
|
package/dist/do.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AttachmentSchema, PinInputSchema, PinSchema, SessionRefSchema, ThreadMessageSchema } from "./schema.js";
|
|
2
2
|
import { a as NotFoundError, i as ConflictError, o as newId, t as SessionSchema } from "./sessions-DrCVTMfI.js";
|
|
3
3
|
import { t as MIGRATIONS } from "./store-IR3o5YwY.js";
|
|
4
|
-
import {
|
|
4
|
+
import { r as drainConnectorPolls } from "./poll-ZSv3wN9C.js";
|
|
5
|
+
import { n as createHubHandler, o as ok, r as err } from "./hub-D-cHpKmA.js";
|
|
5
6
|
import { ClientHelloSchema, WS_CLOSE_PROTOCOL, WS_CLOSE_UNAUTHORIZED, WS_TOKEN_SUBPROTOCOL_PREFIX, encodeWsEvent } from "./ws-protocol.js";
|
|
6
7
|
import { n as DeliveryRouter } from "./router-BH74psWn.js";
|
|
7
8
|
import { createWebhookAdapter } from "./delivery/webhook.js";
|
|
9
|
+
import { n as createSlackTransport, o as createGithubAppTransport, r as handleGithubWebhook, t as createSlackConnector } from "./slack-C4P0AbuZ.js";
|
|
10
|
+
import { createGithubConnector } from "./connectors/github.js";
|
|
8
11
|
import { i as verifyJwt, n as verifyNone, r as verifyToken } from "./verify-BDt9d9Np.js";
|
|
9
12
|
//#region src/do-store-registries.ts
|
|
10
13
|
function parseSession(json) {
|
|
@@ -561,6 +564,8 @@ var DoBroadcaster = class {
|
|
|
561
564
|
return this.state.getWebSockets(topic).length;
|
|
562
565
|
}
|
|
563
566
|
};
|
|
567
|
+
/** Hub-root path of the GitHub webhook receiver (`/_pinbox/webhooks/github` on the Worker). */
|
|
568
|
+
const WEBHOOK_PATH = "/webhooks/github";
|
|
564
569
|
/** Retry cadence for the delivery queue. Coarse: the receiver being down is not urgent. */
|
|
565
570
|
const DRAIN_INTERVAL_MS = 3e4;
|
|
566
571
|
/** Later than any due_at this hub can write, so `due()` answers "anything pending?". */
|
|
@@ -583,6 +588,24 @@ function buildRouter(store, env) {
|
|
|
583
588
|
})]
|
|
584
589
|
});
|
|
585
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* The cloud connector set, from the environment: github when the App quartet is present,
|
|
593
|
+
* slack when its pair is. A partial quartet is ignored rather than half-configured — the
|
|
594
|
+
* link route then answers 502 E_CONNECTOR with the `pinbox doctor` hint, as with no config.
|
|
595
|
+
*/
|
|
596
|
+
function buildConnectors(env) {
|
|
597
|
+
const out = [];
|
|
598
|
+
const present = (v) => v !== void 0 && v !== "";
|
|
599
|
+
if (present(env.GITHUB_APP_ID) && present(env.GITHUB_APP_PRIVATE_KEY) && present(env.GITHUB_INSTALLATION_ID) && present(env.GITHUB_REPO)) out.push(createGithubConnector(createGithubAppTransport({
|
|
600
|
+
appId: env.GITHUB_APP_ID,
|
|
601
|
+
privateKeyPem: env.GITHUB_APP_PRIVATE_KEY,
|
|
602
|
+
installationId: env.GITHUB_INSTALLATION_ID,
|
|
603
|
+
repo: env.GITHUB_REPO,
|
|
604
|
+
...present(env.GITHUB_API_BASE) ? { apiBase: env.GITHUB_API_BASE } : {}
|
|
605
|
+
})));
|
|
606
|
+
if (present(env.SLACK_BOT_TOKEN) && present(env.SLACK_CHANNEL)) out.push(createSlackConnector(createSlackTransport({ botToken: env.SLACK_BOT_TOKEN }), { channel: env.SLACK_CHANNEL }));
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
586
609
|
var PinboxHubDO = class {
|
|
587
610
|
store;
|
|
588
611
|
broadcaster;
|
|
@@ -593,6 +616,8 @@ var PinboxHubDO = class {
|
|
|
593
616
|
handler;
|
|
594
617
|
/** Undefined when no adapter is configured — the hub then stores pins and delivers nothing. */
|
|
595
618
|
router;
|
|
619
|
+
/** Host-injected tracker connectors (github via App token, slack); empty ⇒ link routes 502. */
|
|
620
|
+
connectors;
|
|
596
621
|
constructor(ctx, env) {
|
|
597
622
|
this.ctx = ctx;
|
|
598
623
|
this.env = env;
|
|
@@ -600,10 +625,12 @@ var PinboxHubDO = class {
|
|
|
600
625
|
this.topic = `project:${ctx.id.name ?? ctx.id.toString()}`;
|
|
601
626
|
this.broadcaster = new DoBroadcaster(ctx);
|
|
602
627
|
this.strategy = buildStrategy(env);
|
|
628
|
+
this.connectors = buildConnectors(env);
|
|
603
629
|
this.handler = createHubHandler({
|
|
604
630
|
store: this.store,
|
|
605
631
|
token: env.PINBOX_TOKEN ?? "",
|
|
606
|
-
..."verify" in this.strategy ? { verify: this.strategy.verify } : {}
|
|
632
|
+
..."verify" in this.strategy ? { verify: this.strategy.verify } : {},
|
|
633
|
+
...this.connectors.length > 0 ? { connectors: this.connectors } : {}
|
|
607
634
|
});
|
|
608
635
|
this.store.subscribe((event) => this.broadcaster.publish(this.topic, encodeWsEvent(event)));
|
|
609
636
|
this.router = buildRouter(this.store, env);
|
|
@@ -614,6 +641,10 @@ var PinboxHubDO = class {
|
|
|
614
641
|
});
|
|
615
642
|
this.scheduleDrain();
|
|
616
643
|
}
|
|
644
|
+
if (this.connectors.length > 0) {
|
|
645
|
+
this.store.subscribe(() => void this.scheduleDrain());
|
|
646
|
+
this.scheduleDrain();
|
|
647
|
+
}
|
|
617
648
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
|
|
618
649
|
}
|
|
619
650
|
async fetch(req) {
|
|
@@ -624,11 +655,25 @@ var PinboxHubDO = class {
|
|
|
624
655
|
return await this.intercept(req, url, verify) ?? this.handler(req);
|
|
625
656
|
}
|
|
626
657
|
async intercept(req, url, verify) {
|
|
658
|
+
if (req.method === "POST" && url.pathname === WEBHOOK_PATH) return this.githubWebhook(req);
|
|
627
659
|
if (req.method === "POST" && url.pathname === "/attachments") return await this.unauthorized(verify, req) ?? this.createAttachment(req, url);
|
|
628
660
|
const mediaKey = /^\/media\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
629
661
|
if (req.method === "GET" && mediaKey !== void 0) return await this.unauthorized(verify, req) ?? this.serveMedia(mediaKey);
|
|
630
662
|
return null;
|
|
631
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* GitHub cannot present our credential: the HMAC signature is this route's whole auth
|
|
666
|
+
* (github-webhook.ts), so it sits outside the bearer/JWT gate. Unconfigured ⇒ falls to 404.
|
|
667
|
+
*/
|
|
668
|
+
githubWebhook(req) {
|
|
669
|
+
const secret = this.env.GITHUB_WEBHOOK_SECRET;
|
|
670
|
+
const repo = this.env.GITHUB_REPO;
|
|
671
|
+
if (secret === void 0 || secret === "" || repo === void 0 || repo === "") return null;
|
|
672
|
+
return handleGithubWebhook(req, this.store, {
|
|
673
|
+
secret,
|
|
674
|
+
repo
|
|
675
|
+
});
|
|
676
|
+
}
|
|
632
677
|
webSocketMessage(ws, message) {
|
|
633
678
|
if (typeof message !== "string") {
|
|
634
679
|
this.protocolError(ws, "binary frames are not part of protocol 1");
|
|
@@ -677,17 +722,21 @@ var PinboxHubDO = class {
|
|
|
677
722
|
* DO has been unloaded and woken again.
|
|
678
723
|
*/
|
|
679
724
|
async alarm() {
|
|
680
|
-
if (this.router
|
|
681
|
-
await this.
|
|
725
|
+
if (this.router !== void 0) await this.router.drainDue();
|
|
726
|
+
if (this.connectors.length > 0) await drainConnectorPolls(this.store, this.connectors);
|
|
682
727
|
await this.scheduleDrain();
|
|
683
728
|
}
|
|
684
729
|
/** Re-arm while work remains. Setting an alarm that already exists is a no-op. */
|
|
685
730
|
async scheduleDrain() {
|
|
686
|
-
if (this.
|
|
687
|
-
if (this.store.deliveries.due(FAR_FUTURE).length === 0) return;
|
|
731
|
+
if (!this.hasPendingWork()) return;
|
|
688
732
|
if (await this.ctx.storage.getAlarm() !== null) return;
|
|
689
733
|
await this.ctx.storage.setAlarm(Date.now() + DRAIN_INTERVAL_MS);
|
|
690
734
|
}
|
|
735
|
+
/** Any delivery row pending, or any linked pin with a poll deadline set. */
|
|
736
|
+
hasPendingWork() {
|
|
737
|
+
if (this.router !== void 0 && this.store.deliveries.due(FAR_FUTURE).length > 0) return true;
|
|
738
|
+
return this.connectors.length > 0 && this.store.pinsDueBefore(FAR_FUTURE).length > 0;
|
|
739
|
+
}
|
|
691
740
|
async upgrade(req, url, verify) {
|
|
692
741
|
let token = url.searchParams.get("token");
|
|
693
742
|
let acceptedProtocol;
|
|
@@ -763,4 +812,4 @@ var PinboxHubDO = class {
|
|
|
763
812
|
}
|
|
764
813
|
};
|
|
765
814
|
//#endregion
|
|
766
|
-
export { DoBroadcaster, PinboxHubDO };
|
|
815
|
+
export { DoBroadcaster, PinboxHubDO, buildConnectors };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AppliedEditSchema, AttachmentSchema, PinInputSchema, SessionRefSchema } from "./schema.js";
|
|
2
2
|
import { a as NotFoundError, i as ConflictError, o as newId } from "./sessions-DrCVTMfI.js";
|
|
3
|
-
import { t as POLL_OPEN_MS } from "./poll-
|
|
3
|
+
import { t as POLL_OPEN_MS } from "./poll-ZSv3wN9C.js";
|
|
4
4
|
import { t as buildInjectionContext } from "./context-BHcEpzVb.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
//#region src/routes-links.ts
|
package/dist/hub-server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as registerAttachmentSink, n as createHubHandler } from "./hub-
|
|
1
|
+
import { c as registerAttachmentSink, n as createHubHandler } from "./hub-D-cHpKmA.js";
|
|
2
2
|
import { ClientHelloSchema, WS_CLOSE_PROTOCOL, WS_CLOSE_UNAUTHORIZED, WS_TOKEN_SUBPROTOCOL_PREFIX, encodeWsEvent } from "./ws-protocol.js";
|
|
3
3
|
//#region src/attachments-local.ts
|
|
4
4
|
const EXTENSIONS = {
|
package/dist/hub.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as mustGetPin, i as match, n as createHubHandler, o as ok, r as err, s as readJson, t as BodyNotJsonError } from "./hub-
|
|
1
|
+
import { a as mustGetPin, i as match, n as createHubHandler, o as ok, r as err, s as readJson, t as BodyNotJsonError } from "./hub-D-cHpKmA.js";
|
|
2
2
|
export { BodyNotJsonError, createHubHandler, err, match, mustGetPin, ok, readJson };
|
|
@@ -249,4 +249,4 @@ function advances(next, current) {
|
|
|
249
249
|
return current === null || next > current;
|
|
250
250
|
}
|
|
251
251
|
//#endregion
|
|
252
|
-
export {
|
|
252
|
+
export { createConnectorEvents as a, inboundEvents as i, POLL_RESOLVED_MS as n, outboundCandidates as o, drainConnectorPolls as r, POLL_OPEN_MS as t };
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { i as inboundEvents } from "./poll-ZSv3wN9C.js";
|
|
2
|
+
import { pinsToMarkdown } from "./markdown.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { SignJWT, importPKCS8 } from "jose";
|
|
5
|
+
//#region src/connectors/github-app.ts
|
|
6
|
+
const API_VERSION = "2022-11-28";
|
|
7
|
+
const USER_AGENT = "pinbox";
|
|
8
|
+
/** Mint a fresh installation token this close to expiry. */
|
|
9
|
+
const REFRESH_MARGIN_MS = 6e4;
|
|
10
|
+
/** App JWTs may live 10 minutes; nine leaves room for clock skew on GitHub's side. */
|
|
11
|
+
const APP_JWT_TTL_S = 540;
|
|
12
|
+
const COMMENTS_PER_PAGE = 100;
|
|
13
|
+
const MAX_COMMENT_PAGES = 10;
|
|
14
|
+
/** Ops that address an existing issue by number. */
|
|
15
|
+
const NUMBERED_OPS = /* @__PURE__ */ new Set([
|
|
16
|
+
"issue.comment",
|
|
17
|
+
"issue.view",
|
|
18
|
+
"issue.close",
|
|
19
|
+
"issue.reopen"
|
|
20
|
+
]);
|
|
21
|
+
/** A transport failure carrying the hint the link route surfaces as E_CONNECTOR. */
|
|
22
|
+
var GithubAppError = class extends Error {
|
|
23
|
+
hint;
|
|
24
|
+
status;
|
|
25
|
+
constructor(message, status, hint) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "GithubAppError";
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.hint = hint;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** The App's own credential: a short-lived RS256 JWT with `iss` = app id. */
|
|
33
|
+
async function signAppJwt(appId, privateKeyPem, now = () => Date.now()) {
|
|
34
|
+
const key = await importPKCS8(toPkcs8Pem(privateKeyPem), "RS256");
|
|
35
|
+
const iat = Math.floor(now() / 1e3) - 60;
|
|
36
|
+
return new SignJWT({}).setProtectedHeader({
|
|
37
|
+
alg: "RS256",
|
|
38
|
+
typ: "JWT"
|
|
39
|
+
}).setIssuer(appId).setIssuedAt(iat).setExpirationTime(iat + 60 + APP_JWT_TTL_S).sign(key);
|
|
40
|
+
}
|
|
41
|
+
/** GitHub's standard request headers for the REST API. */
|
|
42
|
+
function githubHeaders(bearer, json = false) {
|
|
43
|
+
return headers(bearer, json);
|
|
44
|
+
}
|
|
45
|
+
/** Exchange an App JWT for a one-hour installation token. */
|
|
46
|
+
async function mintInstallationToken(api, installationId, appJwt, fetchImpl) {
|
|
47
|
+
const res = await fetchImpl(`${api}/app/installations/${installationId}/access_tokens`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: headers(appJwt)
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new GithubAppError(`GitHub App token request failed: HTTP ${res.status}`, res.status, res.status === 401 ? "check GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY belong to the same App" : res.status === 404 ? "check GITHUB_INSTALLATION_ID — the App may not be installed on this repo's owner" : void 0);
|
|
52
|
+
const body = await res.json();
|
|
53
|
+
if (typeof body.token !== "string" || typeof body.expires_at !== "string") throw new GithubAppError("GitHub App token response was not { token, expires_at }", 502);
|
|
54
|
+
return {
|
|
55
|
+
token: body.token,
|
|
56
|
+
expiresAt: Date.parse(body.expires_at)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createGithubAppTransport(opts) {
|
|
60
|
+
const fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
61
|
+
const now = opts.now ?? (() => Date.now());
|
|
62
|
+
const api = (opts.apiBase ?? "https://api.github.com").replace(/\/+$/, "");
|
|
63
|
+
const repo = opts.repo.replace(/^\/+|\/+$/g, "");
|
|
64
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new GithubAppError(`GITHUB_REPO must be "owner/name", got "${opts.repo}"`, 0);
|
|
65
|
+
let cached = null;
|
|
66
|
+
async function installationToken() {
|
|
67
|
+
if (cached !== null && cached.expiresAt - now() > REFRESH_MARGIN_MS) return cached.token;
|
|
68
|
+
const jwt = await signAppJwt(opts.appId, opts.privateKeyPem, now);
|
|
69
|
+
cached = await mintInstallationToken(api, opts.installationId, jwt, fetchImpl);
|
|
70
|
+
return cached.token;
|
|
71
|
+
}
|
|
72
|
+
async function call(method, path, body) {
|
|
73
|
+
const res = await fetchImpl(`${api}${path}`, {
|
|
74
|
+
method,
|
|
75
|
+
headers: headers(await installationToken(), body !== void 0),
|
|
76
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const detail = await res.text().catch(() => "");
|
|
80
|
+
if (res.status === 401) cached = null;
|
|
81
|
+
throw new GithubAppError(`GitHub ${method} ${path} failed: HTTP ${res.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`, res.status, res.status === 403 || res.status === 404 ? `check the App is installed on ${repo} with Issues: read & write` : void 0);
|
|
82
|
+
}
|
|
83
|
+
return await res.json();
|
|
84
|
+
}
|
|
85
|
+
async function allComments(number) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
88
|
+
const batch = await call("GET", `/repos/${repo}/issues/${number}/comments?per_page=${COMMENTS_PER_PAGE}&page=${page}`);
|
|
89
|
+
out.push(...batch);
|
|
90
|
+
if (batch.length < COMMENTS_PER_PAGE) break;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
return { request: (op, params) => dispatch(op, params, {
|
|
95
|
+
repo,
|
|
96
|
+
call,
|
|
97
|
+
allComments
|
|
98
|
+
}) };
|
|
99
|
+
}
|
|
100
|
+
/** The pinned op vocabulary (github.ts) over GitHub's REST paths. */
|
|
101
|
+
async function dispatch(op, params, api) {
|
|
102
|
+
const { repo, call } = api;
|
|
103
|
+
const number = Number(params["number"]);
|
|
104
|
+
if (NUMBERED_OPS.has(op) && (!Number.isInteger(number) || number <= 0)) throw new GithubAppError(`github ${op} needs a positive issue number`, 0);
|
|
105
|
+
switch (op) {
|
|
106
|
+
case "issue.create": {
|
|
107
|
+
const issue = await call("POST", `/repos/${repo}/issues`, {
|
|
108
|
+
title: params["title"],
|
|
109
|
+
body: params["body"]
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
number: issue.number,
|
|
113
|
+
url: issue.html_url
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "issue.comment":
|
|
117
|
+
await call("POST", `/repos/${repo}/issues/${number}/comments`, { body: params["body"] });
|
|
118
|
+
return;
|
|
119
|
+
case "issue.view": {
|
|
120
|
+
const issue = await call("GET", `/repos/${repo}/issues/${number}`);
|
|
121
|
+
const comments = await api.allComments(number);
|
|
122
|
+
return {
|
|
123
|
+
state: issue.state === "closed" ? "closed" : "open",
|
|
124
|
+
comments: comments.map((c) => ({
|
|
125
|
+
author: c.user?.login ?? "ghost",
|
|
126
|
+
body: c.body ?? "",
|
|
127
|
+
createdAt: c.created_at
|
|
128
|
+
}))
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
case "issue.close":
|
|
132
|
+
await call("PATCH", `/repos/${repo}/issues/${number}`, { state: "closed" });
|
|
133
|
+
return;
|
|
134
|
+
case "issue.reopen":
|
|
135
|
+
await call("PATCH", `/repos/${repo}/issues/${number}`, { state: "open" });
|
|
136
|
+
return;
|
|
137
|
+
default: throw new GithubAppError(`unknown github op: ${op}`, 0);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function headers(bearer, json = false) {
|
|
141
|
+
return {
|
|
142
|
+
authorization: `Bearer ${bearer}`,
|
|
143
|
+
accept: "application/vnd.github+json",
|
|
144
|
+
"x-github-api-version": API_VERSION,
|
|
145
|
+
"user-agent": USER_AGENT,
|
|
146
|
+
...json ? { "content-type": "application/json" } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** PKCS#8 PEM in, PKCS#8 PEM out; PKCS#1 PEM (GitHub's download) is wrapped into PKCS#8. */
|
|
150
|
+
function toPkcs8Pem(pem) {
|
|
151
|
+
const trimmed = pem.trim();
|
|
152
|
+
if (trimmed.includes("BEGIN PRIVATE KEY")) return trimmed;
|
|
153
|
+
if (!trimmed.includes("BEGIN RSA PRIVATE KEY")) throw new GithubAppError("GITHUB_APP_PRIVATE_KEY is not a PEM private key", 0, "paste the whole file GitHub downloaded, \"-----BEGIN RSA PRIVATE KEY-----\" through the END line");
|
|
154
|
+
return toPem("PRIVATE KEY", pkcs1ToPkcs8(pemBody(trimmed)));
|
|
155
|
+
}
|
|
156
|
+
function pemBody(pem) {
|
|
157
|
+
const b64 = pem.split("\n").filter((line) => !line.startsWith("-----")).join("").replace(/\s+/g, "");
|
|
158
|
+
const bin = atob(b64);
|
|
159
|
+
const out = new Uint8Array(bin.length);
|
|
160
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function toPem(label, der) {
|
|
164
|
+
let bin = "";
|
|
165
|
+
for (const b of der) bin += String.fromCharCode(b);
|
|
166
|
+
return `-----BEGIN ${label}-----\n${btoa(bin).replace(/(.{64})/g, "$1\n").trimEnd()}\n-----END ${label}-----`;
|
|
167
|
+
}
|
|
168
|
+
/** rsaEncryption AlgorithmIdentifier: SEQUENCE { OID 1.2.840.113549.1.1.1, NULL }. */
|
|
169
|
+
const RSA_ALGORITHM = new Uint8Array([
|
|
170
|
+
48,
|
|
171
|
+
13,
|
|
172
|
+
6,
|
|
173
|
+
9,
|
|
174
|
+
42,
|
|
175
|
+
134,
|
|
176
|
+
72,
|
|
177
|
+
134,
|
|
178
|
+
247,
|
|
179
|
+
13,
|
|
180
|
+
1,
|
|
181
|
+
1,
|
|
182
|
+
1,
|
|
183
|
+
5,
|
|
184
|
+
0
|
|
185
|
+
]);
|
|
186
|
+
const VERSION_ZERO = new Uint8Array([
|
|
187
|
+
2,
|
|
188
|
+
1,
|
|
189
|
+
0
|
|
190
|
+
]);
|
|
191
|
+
/** PrivateKeyInfo ::= SEQUENCE { version 0, algorithm rsaEncryption, privateKey OCTET STRING }. */
|
|
192
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
193
|
+
return derTlv(48, concat(VERSION_ZERO, RSA_ALGORITHM, derTlv(4, pkcs1)));
|
|
194
|
+
}
|
|
195
|
+
function derTlv(tag, body) {
|
|
196
|
+
const len = body.length;
|
|
197
|
+
let header;
|
|
198
|
+
if (len < 128) header = [tag, len];
|
|
199
|
+
else {
|
|
200
|
+
const bytes = [];
|
|
201
|
+
for (let v = len; v > 0; v = Math.floor(v / 256)) bytes.unshift(v & 255);
|
|
202
|
+
header = [
|
|
203
|
+
tag,
|
|
204
|
+
128 | bytes.length,
|
|
205
|
+
...bytes
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
return concat(new Uint8Array(header), body);
|
|
209
|
+
}
|
|
210
|
+
function concat(...parts) {
|
|
211
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
212
|
+
let at = 0;
|
|
213
|
+
for (const p of parts) {
|
|
214
|
+
out.set(p, at);
|
|
215
|
+
at += p.length;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/connectors/github-webhook.ts
|
|
221
|
+
/** Trailer marking bodies pinbox itself wrote (github.ts); never mirror those back in. */
|
|
222
|
+
const PINBOX_TRAILER = "— pinbox";
|
|
223
|
+
/**
|
|
224
|
+
* Handle one webhook delivery. Returns the hub's machine envelope: 200 with
|
|
225
|
+
* `{ applied, ignored }`, 401 `E_AUTH` on a signature failure, 400 `E_INVALID_INPUT` on a
|
|
226
|
+
* body GitHub would never send.
|
|
227
|
+
*/
|
|
228
|
+
async function handleGithubWebhook(req, store, opts) {
|
|
229
|
+
const raw = await req.text();
|
|
230
|
+
const signature = req.headers.get("x-hub-signature-256");
|
|
231
|
+
if (!await signatureValid(opts.secret, raw, signature)) return envelope(401, {
|
|
232
|
+
code: "E_AUTH",
|
|
233
|
+
message: "webhook signature missing or invalid",
|
|
234
|
+
hint: "the App's webhook secret must equal GITHUB_WEBHOOK_SECRET on the hub"
|
|
235
|
+
});
|
|
236
|
+
let payload;
|
|
237
|
+
try {
|
|
238
|
+
payload = JSON.parse(raw);
|
|
239
|
+
} catch {
|
|
240
|
+
return envelope(400, {
|
|
241
|
+
code: "E_INVALID_INPUT",
|
|
242
|
+
message: "webhook body is not JSON"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const event = req.headers.get("x-github-event") ?? "";
|
|
246
|
+
return envelope(200, void 0, await apply(store, opts.repo, event, payload));
|
|
247
|
+
}
|
|
248
|
+
async function apply(store, repo, event, payload) {
|
|
249
|
+
if (event === "ping") return {
|
|
250
|
+
applied: 0,
|
|
251
|
+
ignored: "ping"
|
|
252
|
+
};
|
|
253
|
+
if (event !== "issues" && event !== "issue_comment") return {
|
|
254
|
+
applied: 0,
|
|
255
|
+
ignored: `event ${event}`
|
|
256
|
+
};
|
|
257
|
+
const body = payload;
|
|
258
|
+
const fullName = body.repository?.full_name;
|
|
259
|
+
if (typeof fullName !== "string" || fullName.toLowerCase() !== repo.toLowerCase()) return {
|
|
260
|
+
applied: 0,
|
|
261
|
+
ignored: `repository ${fullName ?? "?"}`
|
|
262
|
+
};
|
|
263
|
+
const number = body.issue?.number;
|
|
264
|
+
if (typeof number !== "number") return {
|
|
265
|
+
applied: 0,
|
|
266
|
+
ignored: "no issue"
|
|
267
|
+
};
|
|
268
|
+
const row = store.links.all().find((r) => r.link.connector === "github" && r.link.ref === String(number));
|
|
269
|
+
if (row === void 0) return {
|
|
270
|
+
applied: 0,
|
|
271
|
+
ignored: `issue #${number} is not linked`
|
|
272
|
+
};
|
|
273
|
+
const { events } = inboundEvents(store, row.pinId, "github", false);
|
|
274
|
+
return event === "issue_comment" ? applyComment(body, row.link, events) : applyStatus(body, row.link, events);
|
|
275
|
+
}
|
|
276
|
+
async function applyComment(body, link, events) {
|
|
277
|
+
if (body.action !== "created") return {
|
|
278
|
+
applied: 0,
|
|
279
|
+
ignored: `issue_comment ${body.action}`
|
|
280
|
+
};
|
|
281
|
+
const comment = body.comment;
|
|
282
|
+
const text = comment?.body ?? "";
|
|
283
|
+
if (isOwnMirror(text) || comment?.user?.type === "Bot") return {
|
|
284
|
+
applied: 0,
|
|
285
|
+
ignored: "own mirror"
|
|
286
|
+
};
|
|
287
|
+
await events.onRemoteComment(link, {
|
|
288
|
+
origin: `github:${comment?.user?.login ?? "ghost"}`,
|
|
289
|
+
text,
|
|
290
|
+
at: comment?.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
applied: 1,
|
|
294
|
+
ignored: null
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async function applyStatus(body, link, events) {
|
|
298
|
+
if (body.action === "closed") {
|
|
299
|
+
await events.onRemoteStatus(link, "closed");
|
|
300
|
+
return {
|
|
301
|
+
applied: 1,
|
|
302
|
+
ignored: null
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (body.action === "reopened") {
|
|
306
|
+
await events.onRemoteStatus(link, "open");
|
|
307
|
+
return {
|
|
308
|
+
applied: 1,
|
|
309
|
+
ignored: null
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
applied: 0,
|
|
314
|
+
ignored: `issues ${body.action}`
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function isOwnMirror(text) {
|
|
318
|
+
return (text.trimEnd().split("\n").at(-1) ?? "").startsWith(PINBOX_TRAILER);
|
|
319
|
+
}
|
|
320
|
+
/** `sha256=<hex hmac>` over the raw body, compared in constant time. */
|
|
321
|
+
async function signatureValid(secret, rawBody, header) {
|
|
322
|
+
if (header === null || !header.startsWith("sha256=")) return false;
|
|
323
|
+
const expected = await hmacHex(secret, rawBody);
|
|
324
|
+
return timingSafeEqual(header.slice(7), expected);
|
|
325
|
+
}
|
|
326
|
+
async function hmacHex(secret, body) {
|
|
327
|
+
const enc = new TextEncoder();
|
|
328
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), {
|
|
329
|
+
name: "HMAC",
|
|
330
|
+
hash: "SHA-256"
|
|
331
|
+
}, false, ["sign"]);
|
|
332
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(body)));
|
|
333
|
+
let hex = "";
|
|
334
|
+
for (const b of sig) hex += b.toString(16).padStart(2, "0");
|
|
335
|
+
return hex;
|
|
336
|
+
}
|
|
337
|
+
function timingSafeEqual(a, b) {
|
|
338
|
+
if (a.length !== b.length) return false;
|
|
339
|
+
let diff = 0;
|
|
340
|
+
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
341
|
+
return diff === 0;
|
|
342
|
+
}
|
|
343
|
+
function envelope(status, error, data) {
|
|
344
|
+
return new Response(JSON.stringify(error === void 0 ? {
|
|
345
|
+
ok: true,
|
|
346
|
+
data
|
|
347
|
+
} : {
|
|
348
|
+
ok: false,
|
|
349
|
+
error
|
|
350
|
+
}), {
|
|
351
|
+
status,
|
|
352
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/connectors/slack.ts
|
|
357
|
+
/**
|
|
358
|
+
* request(op, params) → POST https://slack.com/api/<op> (JSON, bearer botToken).
|
|
359
|
+
* Slack's `{ok:false, error}` becomes a rejection carrying the Slack error string —
|
|
360
|
+
* the route layer surfaces it as 502 E_CONNECTOR.
|
|
361
|
+
*/
|
|
362
|
+
function createSlackTransport(opts) {
|
|
363
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
364
|
+
return { async request(op, params) {
|
|
365
|
+
const res = await fetchImpl(`https://slack.com/api/${op}`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: {
|
|
368
|
+
authorization: `Bearer ${opts.botToken}`,
|
|
369
|
+
"content-type": "application/json; charset=utf-8"
|
|
370
|
+
},
|
|
371
|
+
body: JSON.stringify(params)
|
|
372
|
+
});
|
|
373
|
+
if (!res.ok) throw new Error(`slack ${op} failed: HTTP ${res.status}`);
|
|
374
|
+
const data = await res.json();
|
|
375
|
+
const envelope = SlackEnvelopeSchema.parse(data);
|
|
376
|
+
if (!envelope.ok) throw new Error(`slack ${op} failed: ${envelope.error ?? "unknown_error"}`);
|
|
377
|
+
return data;
|
|
378
|
+
} };
|
|
379
|
+
}
|
|
380
|
+
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
381
|
+
function createSlackConnector(transport, opts) {
|
|
382
|
+
return {
|
|
383
|
+
name: "slack",
|
|
384
|
+
async createItem(pin, thread) {
|
|
385
|
+
const text = [pinsToMarkdown([pin], "standard"), ...thread.map((m) => `${m.role}: ${m.text}`)].join("\n").trimEnd();
|
|
386
|
+
const posted = PostMessageSchema.parse(await transport.request("chat.postMessage", {
|
|
387
|
+
channel: opts.channel,
|
|
388
|
+
text
|
|
389
|
+
}));
|
|
390
|
+
const channel = posted.channel ?? opts.channel;
|
|
391
|
+
const permalink = PermalinkSchema.parse(await transport.request("chat.getPermalink", {
|
|
392
|
+
channel,
|
|
393
|
+
message_ts: posted.ts
|
|
394
|
+
}));
|
|
395
|
+
return {
|
|
396
|
+
connector: "slack",
|
|
397
|
+
ref: `${channel}/${posted.ts}`,
|
|
398
|
+
url: permalink.permalink
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
async postComment(link, message) {
|
|
402
|
+
const { channel, ts } = parseRef(link.ref);
|
|
403
|
+
await transport.request("chat.postMessage", {
|
|
404
|
+
channel,
|
|
405
|
+
thread_ts: ts,
|
|
406
|
+
text: message.text
|
|
407
|
+
});
|
|
408
|
+
},
|
|
409
|
+
async sync(link, events) {
|
|
410
|
+
const { channel, ts } = parseRef(link.ref);
|
|
411
|
+
const replies = RepliesSchema.parse(await transport.request("conversations.replies", {
|
|
412
|
+
channel,
|
|
413
|
+
ts
|
|
414
|
+
}));
|
|
415
|
+
for (const reply of replies.messages) {
|
|
416
|
+
if (reply.ts === ts) continue;
|
|
417
|
+
const atMs = slackTsToMs(reply.ts);
|
|
418
|
+
await events.onRemoteComment(link, {
|
|
419
|
+
origin: `slack:${reply.user}`,
|
|
420
|
+
text: reply.text ?? "",
|
|
421
|
+
at: new Date(atMs).toISOString()
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
async setRemoteStatus() {}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const SlackEnvelopeSchema = z.looseObject({
|
|
429
|
+
ok: z.boolean(),
|
|
430
|
+
error: z.string().optional()
|
|
431
|
+
});
|
|
432
|
+
const PostMessageSchema = z.looseObject({
|
|
433
|
+
channel: z.string().optional(),
|
|
434
|
+
ts: z.string()
|
|
435
|
+
});
|
|
436
|
+
const PermalinkSchema = z.looseObject({ permalink: z.string() });
|
|
437
|
+
const RepliesSchema = z.looseObject({ messages: z.array(z.looseObject({
|
|
438
|
+
ts: z.string(),
|
|
439
|
+
user: z.string(),
|
|
440
|
+
text: z.string().optional()
|
|
441
|
+
})) });
|
|
442
|
+
function parseRef(ref) {
|
|
443
|
+
const slash = ref.indexOf("/");
|
|
444
|
+
if (slash <= 0 || slash === ref.length - 1) throw new Error(`slack link ref must be "<channel>/<thread_ts>", got "${ref}"`);
|
|
445
|
+
return {
|
|
446
|
+
channel: ref.slice(0, slash),
|
|
447
|
+
ts: ref.slice(slash + 1)
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** Slack ts is "<epoch-seconds>.<suffix>" — epoch milliseconds for the reported `at`. */
|
|
451
|
+
function slackTsToMs(ts) {
|
|
452
|
+
return Number(ts) * 1e3;
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
export { GithubAppError as a, mintInstallationToken as c, toPkcs8Pem as d, signatureValid as i, pkcs1ToPkcs8 as l, createSlackTransport as n, createGithubAppTransport as o, handleGithubWebhook as r, githubHeaders as s, createSlackConnector as t, signAppJwt as u };
|