@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.
@@ -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 };
@@ -1,103 +1,3 @@
1
- import { a as outboundCandidates, i as createConnectorEvents, n as POLL_RESOLVED_MS, r as drainConnectorPolls, t as POLL_OPEN_MS } from "../poll-BrcAuaAz.js";
2
- import { pinsToMarkdown } from "../markdown.js";
3
- import { z } from "zod";
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 { n as createHubHandler, o as ok, r as err } from "./hub-I67BuX9S.js";
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 === void 0) return;
681
- await this.router.drainDue();
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.router === void 0) return;
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-BrcAuaAz.js";
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
@@ -1,4 +1,4 @@
1
- import { c as registerAttachmentSink, n as createHubHandler } from "./hub-I67BuX9S.js";
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-I67BuX9S.js";
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 { outboundCandidates as a, createConnectorEvents as i, POLL_RESOLVED_MS as n, drainConnectorPolls as r, POLL_OPEN_MS as t };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autono/pinbox-core",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {