@openthink/stamp 2.1.0 → 2.2.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.
@@ -2,6 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  // src/server/http-server.ts
5
+ var import_node_crypto4 = require("crypto");
5
6
  var import_node_http = require("http");
6
7
 
7
8
  // src/lib/invites.ts
@@ -184,9 +185,225 @@ function sshFingerprintFromBlob(keyBlob) {
184
185
  return `SHA256:${b64}`;
185
186
  }
186
187
 
188
+ // src/server/prompts-cache.ts
189
+ var import_node_fs4 = require("fs");
190
+ var import_node_child_process = require("child_process");
191
+ var import_node_url = require("url");
192
+ var import_node_path3 = require("path");
193
+
194
+ // src/server/promptFetch.ts
195
+ var import_node_fs3 = require("fs");
196
+ var import_node_crypto3 = require("crypto");
197
+ var MAX_PROMPT_BYTES = 1024 * 1024;
198
+
199
+ // src/server/prompts-cache.ts
200
+ var import_meta = {};
201
+ var LOCK_STALE_MS = 5 * 60 * 1e3;
202
+ function defaultKnownHostsPath() {
203
+ return (0, import_node_path3.resolve)(
204
+ (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)),
205
+ "..",
206
+ "..",
207
+ "server",
208
+ "github-known-hosts"
209
+ );
210
+ }
211
+ var inflightRefreshes = /* @__PURE__ */ new Map();
212
+ async function cloneOrFetchPromptsCache(opts) {
213
+ validateOpts(opts);
214
+ const cacheRoot = (0, import_node_path3.resolve)(opts.cacheRoot);
215
+ const existing = inflightRefreshes.get(cacheRoot);
216
+ if (existing) return existing;
217
+ const promise = (async () => {
218
+ const lockPath = `${cacheRoot}.refresh.lock`;
219
+ acquireLock(lockPath);
220
+ try {
221
+ return await refreshInternal({ ...opts, cacheRoot });
222
+ } finally {
223
+ releaseLock(lockPath);
224
+ }
225
+ })();
226
+ inflightRefreshes.set(cacheRoot, promise);
227
+ promise.finally(() => {
228
+ if (inflightRefreshes.get(cacheRoot) === promise) {
229
+ inflightRefreshes.delete(cacheRoot);
230
+ }
231
+ }).catch(() => {
232
+ });
233
+ return promise;
234
+ }
235
+ function validateOpts(opts) {
236
+ if (!opts || typeof opts !== "object") {
237
+ throw new Error("cloneOrFetchPromptsCache: opts must be an object");
238
+ }
239
+ if (!opts.url || typeof opts.url !== "string") {
240
+ throw new Error("cloneOrFetchPromptsCache: url is required");
241
+ }
242
+ if (!opts.ref || typeof opts.ref !== "string") {
243
+ throw new Error("cloneOrFetchPromptsCache: ref is required");
244
+ }
245
+ if (!opts.cacheRoot || typeof opts.cacheRoot !== "string") {
246
+ throw new Error("cloneOrFetchPromptsCache: cacheRoot is required");
247
+ }
248
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,200}$/.test(opts.ref)) {
249
+ throw new Error(
250
+ `cloneOrFetchPromptsCache: ref ${JSON.stringify(opts.ref)} contains characters not allowed in a git refspec`
251
+ );
252
+ }
253
+ }
254
+ function acquireLock(lockPath) {
255
+ (0, import_node_fs4.mkdirSync)((0, import_node_path3.dirname)(lockPath), { recursive: true });
256
+ try {
257
+ const fd = (0, import_node_fs4.openSync)(lockPath, "wx");
258
+ (0, import_node_fs4.closeSync)(fd);
259
+ return;
260
+ } catch (err) {
261
+ const e = err;
262
+ if (e.code !== "EEXIST") {
263
+ throw new Error(
264
+ `prompts-cache: could not create lock file ${lockPath}: ${err.message}`
265
+ );
266
+ }
267
+ }
268
+ let lockStat;
269
+ try {
270
+ lockStat = (0, import_node_fs4.statSync)(lockPath);
271
+ } catch (err) {
272
+ try {
273
+ const fd = (0, import_node_fs4.openSync)(lockPath, "wx");
274
+ (0, import_node_fs4.closeSync)(fd);
275
+ return;
276
+ } catch (err2) {
277
+ throw new Error(
278
+ `prompts-cache: lock-file race on ${lockPath}: ${err2.message}`
279
+ );
280
+ }
281
+ }
282
+ const age = Date.now() - lockStat.mtimeMs;
283
+ if (age > LOCK_STALE_MS) {
284
+ (0, import_node_fs4.rmSync)(lockPath, { force: true });
285
+ const fd = (0, import_node_fs4.openSync)(lockPath, "wx");
286
+ (0, import_node_fs4.closeSync)(fd);
287
+ return;
288
+ }
289
+ throw new Error(
290
+ `prompts-cache: refresh already in progress (lock ${lockPath} held, ${Math.round(age / 1e3)}s old)`
291
+ );
292
+ }
293
+ function releaseLock(lockPath) {
294
+ try {
295
+ (0, import_node_fs4.rmSync)(lockPath, { force: true });
296
+ } catch {
297
+ }
298
+ }
299
+ async function refreshInternal(opts) {
300
+ const { url, ref, cacheRoot, deployKeyPath } = opts;
301
+ const env = buildGitEnv(deployKeyPath);
302
+ const tmpPath = `${cacheRoot}.tmp`;
303
+ if ((0, import_node_fs4.existsSync)(tmpPath)) {
304
+ (0, import_node_fs4.rmSync)(tmpPath, { recursive: true, force: true });
305
+ }
306
+ const cacheIsCheckout = (0, import_node_fs4.existsSync)(cacheRoot) && (0, import_node_fs4.existsSync)(`${cacheRoot}/.git`);
307
+ if (cacheIsCheckout) {
308
+ try {
309
+ runGit(cacheRoot, ["remote", "set-url", "origin", url], env);
310
+ runGit(cacheRoot, ["fetch", "--prune", "origin", ref], env);
311
+ runGit(cacheRoot, ["checkout", ref], env);
312
+ runGit(cacheRoot, ["reset", "--hard", `origin/${ref}`], env);
313
+ const commitSha2 = runGit(cacheRoot, ["rev-parse", "HEAD"], env).trim();
314
+ return { commitSha: commitSha2, refreshedAt: (/* @__PURE__ */ new Date()).toISOString() };
315
+ } catch (err) {
316
+ const reason = err instanceof Error ? err.message : String(err);
317
+ process.stderr.write(
318
+ `prompts-cache: in-place fetch failed (${reason}), falling back to atomic rebuild
319
+ `
320
+ );
321
+ }
322
+ }
323
+ (0, import_node_fs4.mkdirSync)((0, import_node_path3.dirname)(cacheRoot), { recursive: true });
324
+ runGit((0, import_node_path3.dirname)(cacheRoot), ["clone", "--quiet", "--branch", ref, url, tmpPath], env);
325
+ const commitSha = runGit(tmpPath, ["rev-parse", "HEAD"], env).trim();
326
+ if (!/^[0-9a-f]{40}$/.test(commitSha)) {
327
+ throw new Error(
328
+ `prompts-cache: rev-parse HEAD in ${tmpPath} returned non-SHA ${JSON.stringify(commitSha)}`
329
+ );
330
+ }
331
+ if ((0, import_node_fs4.existsSync)(cacheRoot)) {
332
+ const oldPath = `${cacheRoot}.old`;
333
+ if ((0, import_node_fs4.existsSync)(oldPath)) {
334
+ (0, import_node_fs4.rmSync)(oldPath, { recursive: true, force: true });
335
+ }
336
+ (0, import_node_fs4.renameSync)(cacheRoot, oldPath);
337
+ try {
338
+ (0, import_node_fs4.renameSync)(tmpPath, cacheRoot);
339
+ } catch (err) {
340
+ try {
341
+ (0, import_node_fs4.renameSync)(oldPath, cacheRoot);
342
+ } catch {
343
+ }
344
+ throw err;
345
+ }
346
+ (0, import_node_fs4.rmSync)(oldPath, { recursive: true, force: true });
347
+ } else {
348
+ (0, import_node_fs4.renameSync)(tmpPath, cacheRoot);
349
+ }
350
+ return { commitSha, refreshedAt: (/* @__PURE__ */ new Date()).toISOString() };
351
+ }
352
+ function buildGitEnv(deployKeyPath) {
353
+ const env = { ...process.env };
354
+ if (!deployKeyPath) return env;
355
+ if (!(0, import_node_fs4.existsSync)(deployKeyPath)) {
356
+ throw new Error(
357
+ `prompts-cache: deployKeyPath ${deployKeyPath} does not exist \u2014 operator must provision the private SSH key`
358
+ );
359
+ }
360
+ const knownHostsPath = process.env["GIT_SSH_KNOWN_HOSTS"] || defaultKnownHostsPath();
361
+ if (!(0, import_node_fs4.existsSync)(knownHostsPath)) {
362
+ throw new Error(
363
+ `prompts-cache: known-hosts file ${knownHostsPath} does not exist \u2014 image build is missing server/github-known-hosts`
364
+ );
365
+ }
366
+ env["GIT_SSH_COMMAND"] = [
367
+ "ssh",
368
+ "-i",
369
+ quoteForSshCommand(deployKeyPath),
370
+ "-o",
371
+ "StrictHostKeyChecking=yes",
372
+ "-o",
373
+ `UserKnownHostsFile=${quoteForSshCommand(knownHostsPath)}`,
374
+ "-o",
375
+ "IdentitiesOnly=yes"
376
+ ].join(" ");
377
+ return env;
378
+ }
379
+ function quoteForSshCommand(s) {
380
+ return `'${s.replace(/'/g, "'\\''")}'`;
381
+ }
382
+ function runGit(cwd, args, env) {
383
+ try {
384
+ return (0, import_node_child_process.execFileSync)("git", args, {
385
+ cwd,
386
+ env,
387
+ encoding: "utf8",
388
+ stdio: ["ignore", "pipe", "pipe"]
389
+ });
390
+ } catch (err) {
391
+ const e = err;
392
+ const stderr = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
393
+ throw new Error(
394
+ `git ${args.join(" ")} (cwd=${cwd}) failed: ${e.message ?? String(err)}${stderr ? `
395
+ stderr: ${stderr.trim()}` : ""}`
396
+ );
397
+ }
398
+ }
399
+
187
400
  // src/server/http-server.ts
188
401
  var DEFAULT_PORT = 8080;
189
402
  var MAX_BODY_BYTES = 16 * 1024;
403
+ var WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
404
+ var WEBHOOK_COALESCE_WINDOW_MS = 5e3;
405
+ var DEFAULT_PROMPTS_CACHE_ROOT = "/srv/git/.prompts-cache";
406
+ var DEFAULT_DEPLOY_KEY_PATH = "/srv/git/.ssh-client-keys/prompts_repo_key";
190
407
  var SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
191
408
  var STAMP_PUBKEY_PEM_RE = /^\s*-----BEGIN PUBLIC KEY-----[A-Za-z0-9+/=\s]+-----END PUBLIC KEY-----\s*$/;
192
409
  function logLine(level, msg) {
@@ -206,7 +423,7 @@ function sendJson(res, status, body) {
206
423
  });
207
424
  res.end(payload);
208
425
  }
209
- async function readBody(req) {
426
+ async function readBody(req, maxBytes = MAX_BODY_BYTES) {
210
427
  return new Promise((resolve2, reject) => {
211
428
  const chunks = [];
212
429
  let total = 0;
@@ -214,7 +431,7 @@ async function readBody(req) {
214
431
  req.on("data", (chunk) => {
215
432
  if (tooLarge) return;
216
433
  total += chunk.length;
217
- if (total > MAX_BODY_BYTES) {
434
+ if (total > maxBytes) {
218
435
  tooLarge = true;
219
436
  chunks.length = 0;
220
437
  return;
@@ -366,6 +583,250 @@ async function handlePost(req, res) {
366
583
  sendJson(res, 500, { ok: false, error: "internal_error" });
367
584
  }
368
585
  }
586
+ var DELIVERY_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
587
+ var webhookState = {
588
+ lastKickoffAt: 0,
589
+ pendingTrailingRefresh: false,
590
+ inflight: null
591
+ };
592
+ var refreshFn = cloneOrFetchPromptsCache;
593
+ function buildRefreshOpts() {
594
+ const url = process.env["STAMP_PROMPTS_REPO_URL"];
595
+ if (!url) return null;
596
+ const ref = process.env["STAMP_PROMPTS_REPO_REF"] || "main";
597
+ const cacheRoot = process.env["STAMP_PROMPTS_CACHE_ROOT"] || DEFAULT_PROMPTS_CACHE_ROOT;
598
+ const deployKeyPath = process.env["STAMP_PROMPTS_DEPLOY_KEY_PATH"] || DEFAULT_DEPLOY_KEY_PATH;
599
+ return { url, ref, cacheRoot, deployKeyPath };
600
+ }
601
+ function verifyWebhookSignature(body, signatureHeader, secret) {
602
+ if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
603
+ return false;
604
+ }
605
+ const providedHex = signatureHeader.slice("sha256=".length);
606
+ if (!/^[0-9a-fA-F]{64}$/.test(providedHex)) {
607
+ return false;
608
+ }
609
+ const provided = Buffer.from(providedHex, "hex");
610
+ const expected = (0, import_node_crypto4.createHmac)("sha256", secret).update(body).digest();
611
+ if (provided.length !== expected.length) {
612
+ return false;
613
+ }
614
+ return (0, import_node_crypto4.timingSafeEqual)(provided, expected);
615
+ }
616
+ function scheduleWebhookRefresh(opts, deliveryId) {
617
+ const now = Date.now();
618
+ const elapsed = now - webhookState.lastKickoffAt;
619
+ if (elapsed >= WEBHOOK_COALESCE_WINDOW_MS) {
620
+ kickoffRefresh(opts, deliveryId);
621
+ return;
622
+ }
623
+ if (webhookState.inflight) {
624
+ if (!webhookState.pendingTrailingRefresh) {
625
+ logLine(
626
+ "info",
627
+ `webhook/prompts coalesced delivery=${deliveryId} (refresh in flight, trailing scheduled)`
628
+ );
629
+ }
630
+ webhookState.pendingTrailingRefresh = true;
631
+ return;
632
+ }
633
+ logLine(
634
+ "info",
635
+ `webhook/prompts coalesced delivery=${deliveryId} (refresh ${Math.round(elapsed)}ms ago, within ${WEBHOOK_COALESCE_WINDOW_MS}ms window)`
636
+ );
637
+ }
638
+ function kickoffRefresh(opts, deliveryId) {
639
+ webhookState.lastKickoffAt = Date.now();
640
+ const promise = refreshFn(opts);
641
+ webhookState.inflight = promise;
642
+ logLine("info", `webhook/prompts refresh start delivery=${deliveryId}`);
643
+ promise.then((result) => {
644
+ logLine(
645
+ "info",
646
+ `webhook/prompts refresh ok delivery=${deliveryId} sha=${result.commitSha} at=${result.refreshedAt}`
647
+ );
648
+ }).catch((err) => {
649
+ const msg = err instanceof Error ? err.message : String(err);
650
+ logLine(
651
+ "error",
652
+ `webhook/prompts refresh failed delivery=${deliveryId}: ${msg}`
653
+ );
654
+ }).finally(() => {
655
+ if (webhookState.inflight === promise) {
656
+ webhookState.inflight = null;
657
+ }
658
+ if (webhookState.pendingTrailingRefresh) {
659
+ webhookState.pendingTrailingRefresh = false;
660
+ kickoffRefresh(opts, `${deliveryId}+trailing`);
661
+ }
662
+ });
663
+ }
664
+ async function handleWebhookPrompts(req, res) {
665
+ const rawDelivery = req.headers["x-github-delivery"];
666
+ const deliveryId = typeof rawDelivery === "string" && DELIVERY_ID_RE.test(rawDelivery) ? rawDelivery : rawDelivery ? "malformed" : "none";
667
+ const remoteAddr = req.socket.remoteAddress ?? "unknown";
668
+ const secret = process.env["STAMP_PROMPTS_WEBHOOK_SECRET"];
669
+ if (!secret) {
670
+ logLine(
671
+ "error",
672
+ `webhook/prompts delivery=${deliveryId} rejected: STAMP_PROMPTS_WEBHOOK_SECRET not configured`
673
+ );
674
+ sendJson(res, 503, {
675
+ ok: false,
676
+ error: "webhook_secret_unconfigured",
677
+ detail: "STAMP_PROMPTS_WEBHOOK_SECRET env var must be set on stamp-server to accept prompt-repo webhooks"
678
+ });
679
+ return;
680
+ }
681
+ let read;
682
+ try {
683
+ read = await readBody(req, WEBHOOK_MAX_BODY_BYTES);
684
+ } catch (e) {
685
+ logLine("warn", `webhook/prompts read body failed delivery=${deliveryId}: ${e.message}`);
686
+ sendJson(res, 400, { ok: false, error: "body_read_failed" });
687
+ return;
688
+ }
689
+ if (read.tooLarge) {
690
+ logLine(
691
+ "warn",
692
+ `webhook/prompts delivery=${deliveryId} from=${remoteAddr} body too large (> ${WEBHOOK_MAX_BODY_BYTES} bytes)`
693
+ );
694
+ sendJson(res, 413, { ok: false, error: "body_too_large" });
695
+ return;
696
+ }
697
+ const sigHeader = req.headers["x-hub-signature-256"];
698
+ const sigValue = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
699
+ if (!verifyWebhookSignature(read.buf, sigValue, secret)) {
700
+ logLine(
701
+ "warn",
702
+ `webhook/prompts delivery=${deliveryId} from=${remoteAddr} rejected: invalid signature`
703
+ );
704
+ sendJson(res, 401, { ok: false, error: "invalid_signature" });
705
+ return;
706
+ }
707
+ const opts = buildRefreshOpts();
708
+ if (!opts) {
709
+ logLine(
710
+ "error",
711
+ `webhook/prompts delivery=${deliveryId} rejected: STAMP_PROMPTS_REPO_URL not configured`
712
+ );
713
+ sendJson(res, 503, {
714
+ ok: false,
715
+ error: "prompts_repo_url_unconfigured",
716
+ detail: "STAMP_PROMPTS_REPO_URL env var must be set on stamp-server to accept prompt-repo webhooks"
717
+ });
718
+ return;
719
+ }
720
+ sendJson(res, 202, { ok: true });
721
+ logLine(
722
+ "info",
723
+ `webhook/prompts delivery=${deliveryId} accepted (signature valid)`
724
+ );
725
+ setImmediate(() => {
726
+ try {
727
+ scheduleWebhookRefresh(opts, deliveryId);
728
+ } catch (e) {
729
+ logLine(
730
+ "error",
731
+ `webhook/prompts schedule error delivery=${deliveryId}: ${e.message}`
732
+ );
733
+ }
734
+ });
735
+ }
736
+ var DEFAULT_PROMPTS_POLL_INTERVAL_SEC = 3600;
737
+ var MIN_PROMPTS_POLL_INTERVAL_SEC = 5;
738
+ var pollState = {
739
+ handle: null,
740
+ inflight: false,
741
+ tickCount: 0
742
+ };
743
+ function resolvePromptsPollIntervalSec() {
744
+ const raw = process.env["STAMP_PROMPTS_POLL_INTERVAL_SEC"];
745
+ if (raw === void 0 || raw === "") {
746
+ return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;
747
+ }
748
+ if (raw === "0") return 0;
749
+ if (!/^-?\d+$/.test(raw)) {
750
+ logLine(
751
+ "warn",
752
+ `STAMP_PROMPTS_POLL_INTERVAL_SEC=${JSON.stringify(raw)} is not a non-negative integer; falling back to default ${DEFAULT_PROMPTS_POLL_INTERVAL_SEC}s`
753
+ );
754
+ return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;
755
+ }
756
+ const n = Number(raw);
757
+ if (!Number.isInteger(n) || n <= 0) {
758
+ logLine(
759
+ "warn",
760
+ `STAMP_PROMPTS_POLL_INTERVAL_SEC=${JSON.stringify(raw)} is not a positive integer (and not the literal '0' opt-out); falling back to default ${DEFAULT_PROMPTS_POLL_INTERVAL_SEC}s`
761
+ );
762
+ return DEFAULT_PROMPTS_POLL_INTERVAL_SEC;
763
+ }
764
+ if (n > 0 && n < MIN_PROMPTS_POLL_INTERVAL_SEC) {
765
+ logLine(
766
+ "warn",
767
+ `STAMP_PROMPTS_POLL_INTERVAL_SEC=${n} is below the floor of ${MIN_PROMPTS_POLL_INTERVAL_SEC}s; clamping to floor`
768
+ );
769
+ return MIN_PROMPTS_POLL_INTERVAL_SEC;
770
+ }
771
+ return n;
772
+ }
773
+ async function runPollTick(opts) {
774
+ if (pollState.inflight) {
775
+ logLine(
776
+ "info",
777
+ "prompts-poll: skipping tick \u2014 previous refresh still in flight"
778
+ );
779
+ return;
780
+ }
781
+ pollState.inflight = true;
782
+ pollState.tickCount += 1;
783
+ try {
784
+ const result = await refreshFn(opts);
785
+ logLine(
786
+ "info",
787
+ `prompts-poll: refresh ok sha=${result.commitSha} at=${result.refreshedAt}`
788
+ );
789
+ } catch (err) {
790
+ const msg = err instanceof Error ? err.message : String(err);
791
+ logLine("error", `prompts-poll: refresh failed: ${msg}`);
792
+ } finally {
793
+ pollState.inflight = false;
794
+ }
795
+ }
796
+ function startPromptsPollWorker() {
797
+ if (pollState.handle !== null) {
798
+ logLine("warn", "prompts-poll: already started \u2014 ignoring duplicate start");
799
+ return;
800
+ }
801
+ const opts = buildRefreshOpts();
802
+ if (!opts) {
803
+ return;
804
+ }
805
+ const intervalSec = resolvePromptsPollIntervalSec();
806
+ if (intervalSec === 0) {
807
+ logLine(
808
+ "info",
809
+ "prompts-poll: disabled (STAMP_PROMPTS_POLL_INTERVAL_SEC=0)"
810
+ );
811
+ return;
812
+ }
813
+ const intervalMs = intervalSec * 1e3;
814
+ const handle = setInterval(() => {
815
+ void runPollTick(opts);
816
+ }, intervalMs);
817
+ handle.unref();
818
+ pollState.handle = handle;
819
+ logLine(
820
+ "info",
821
+ `prompts-poll: started (interval=${intervalSec}s, url=${opts.url}, ref=${opts.ref}, cacheRoot=${opts.cacheRoot})`
822
+ );
823
+ }
824
+ function stopPromptsPollWorker() {
825
+ if (pollState.handle === null) return;
826
+ clearInterval(pollState.handle);
827
+ pollState.handle = null;
828
+ pollState.inflight = false;
829
+ }
369
830
  var HTTP_DEFAULT_PORT = DEFAULT_PORT;
370
831
  function startServer(port2 = DEFAULT_PORT) {
371
832
  const server = (0, import_node_http.createServer)((req, res) => {
@@ -378,10 +839,18 @@ function startServer(port2 = DEFAULT_PORT) {
378
839
  void handlePost(req, res);
379
840
  return;
380
841
  }
842
+ if (req.method === "POST" && url === "/webhook/prompts") {
843
+ void handleWebhookPrompts(req, res);
844
+ return;
845
+ }
381
846
  sendJson(res, 404, { ok: false, error: "not_found" });
382
847
  });
383
848
  server.listen(port2, () => {
384
849
  logLine("info", `listening on :${port2}`);
850
+ startPromptsPollWorker();
851
+ });
852
+ server.once("close", () => {
853
+ stopPromptsPollWorker();
385
854
  });
386
855
  return server;
387
856
  }