@hachej/boring-ask-user 0.1.54 → 0.1.56

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.
@@ -357,15 +357,27 @@ var AskUserRuntime = class {
357
357
  }
358
358
  }
359
359
  async ask(request, signal) {
360
- this.assertAllowed(request.sessionId);
361
- const question = this.createQuestion(request);
360
+ const ownerPrincipalId = request.ownerPrincipalId ?? this.ownerPrincipalId;
361
+ this.assertAllowed(request.sessionId, ownerPrincipalId);
362
+ const question = this.createQuestion({ ...request, ownerPrincipalId });
362
363
  const parsed = AskUserFormSchemaSchema.safeParse(request.schema);
363
364
  if (!parsed.success) throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.SCHEMA_INVALID, parsed.error.message);
364
365
  question.schema = parsed.data;
365
- await this.store.createPending(question);
366
- await this.store.appendTranscriptEvent({ type: "created", question, at: this.isoNow() });
367
- await this.store.appendTranscriptEvent({ type: "ready", questionId: question.questionId, sessionId: question.sessionId, schema: parsed.data, at: this.isoNow() });
368
- return this.waitForAnswerWithOpen(question, request.timeoutMs, signal);
366
+ const pendingAnswer = this.coordinator.registerWaiter(question.questionId, question.sessionId);
367
+ try {
368
+ await this.store.createPending(question);
369
+ await this.store.appendTranscriptEvent({ type: "created", question, at: this.isoNow() });
370
+ await this.store.appendTranscriptEvent({ type: "ready", questionId: question.questionId, sessionId: question.sessionId, schema: parsed.data, at: this.isoNow() });
371
+ if (signal?.aborted) {
372
+ await this.cancelQuestion(question.questionId, question.sessionId, "aborted");
373
+ return await pendingAnswer;
374
+ }
375
+ void this.openQuestionSurface(question);
376
+ return await this.waitForAnswer(question, pendingAnswer, request.timeoutMs, signal);
377
+ } catch (error) {
378
+ this.coordinator.resolveCancelled(question.questionId, "abandoned");
379
+ throw error;
380
+ }
369
381
  }
370
382
  async submitAnswer(questionId, sessionId, values) {
371
383
  const question = await this.store.getByQuestionId(questionId);
@@ -375,9 +387,14 @@ var AskUserRuntime = class {
375
387
  return "abandoned";
376
388
  }
377
389
  const answer = { questionId, sessionId, values, submittedAt: this.isoNow() };
378
- await this.store.answer(questionId, answer);
379
- await this.store.appendTranscriptEvent({ type: "answered", answer, at: this.isoNow() });
380
- this.coordinator.resolveAnswered(questionId, answer);
390
+ let answerPersisted = false;
391
+ try {
392
+ await this.store.answer(questionId, answer);
393
+ answerPersisted = true;
394
+ await this.store.appendTranscriptEvent({ type: "answered", answer, at: this.isoNow() });
395
+ } finally {
396
+ if (answerPersisted) this.coordinator.resolveAnswered(questionId, answer);
397
+ }
381
398
  return "answered";
382
399
  }
383
400
  async cancelQuestion(questionId, sessionId, reason = "user_cancelled") {
@@ -387,41 +404,49 @@ var AskUserRuntime = class {
387
404
  await this.abandon(questionId, sessionId);
388
405
  return;
389
406
  }
390
- await this.store.cancel(questionId);
391
- await this.store.appendTranscriptEvent({ type: "cancelled", questionId, sessionId, reason, at: this.isoNow() });
392
- this.coordinator.resolveCancelled(questionId, reason);
393
- }
394
- async waitForAnswerWithOpen(question, timeoutMs, signal) {
395
- const pendingAnswer = this.waitForAnswer(question, timeoutMs, signal);
396
- void this.openQuestionSurface(question);
397
- return pendingAnswer;
407
+ let cancelPersisted = false;
408
+ try {
409
+ await this.store.cancel(questionId);
410
+ cancelPersisted = true;
411
+ await this.store.appendTranscriptEvent({ type: "cancelled", questionId, sessionId, reason, at: this.isoNow() });
412
+ } catch (error) {
413
+ if (!cancelPersisted) await this.resolveCancelledUnlessAnswered(questionId, reason);
414
+ throw error;
415
+ } finally {
416
+ if (cancelPersisted) this.coordinator.resolveCancelled(questionId, reason);
417
+ }
398
418
  }
399
419
  async openQuestionSurface(question) {
400
420
  if (!this.uiBridge) return;
401
421
  try {
402
- await this.uiBridge.postCommand({ kind: "openSurface", params: { kind: ASK_USER_SURFACE_KIND, target: question.questionId, meta: { question } } });
422
+ await this.uiBridge.postCommand({ kind: "openSurface", params: { kind: ASK_USER_SURFACE_KIND, target: question.questionId, meta: { sessionId: question.sessionId, openOnlyWhenSessionOpen: true } } });
403
423
  } catch {
404
424
  }
405
425
  }
406
- async waitForAnswer(question, timeoutMs, signal) {
407
- const controller = new AbortController();
408
- const relayAbort = () => controller.abort();
409
- signal?.addEventListener("abort", relayAbort, { once: true });
410
- if (signal?.aborted) controller.abort();
411
- const timeout = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
412
- const result = await this.coordinator.registerWaiter(question.questionId, question.sessionId, controller.signal);
413
- signal?.removeEventListener("abort", relayAbort);
414
- if (timeout) clearTimeout(timeout);
415
- if (result.status === "cancelled" && result.reason === "aborted") {
416
- const reason = signal?.aborted ? "aborted" : "timeout";
417
- try {
418
- await this.store.cancel(question.questionId);
419
- await this.store.appendTranscriptEvent({ type: "cancelled", questionId: question.questionId, sessionId: question.sessionId, reason, at: this.isoNow() });
420
- } catch {
421
- }
422
- return { status: "cancelled", questionId: question.questionId, sessionId: question.sessionId, reason };
426
+ async waitForAnswer(question, pendingAnswer, timeoutMs, signal) {
427
+ let settled = false;
428
+ const cancel = (reason) => {
429
+ if (settled) return;
430
+ void this.cancelQuestion(question.questionId, question.sessionId, reason).catch(() => void 0);
431
+ };
432
+ const onAbort = () => cancel("aborted");
433
+ signal?.addEventListener("abort", onAbort, { once: true });
434
+ if (signal?.aborted) cancel("aborted");
435
+ const timeout = timeoutMs ? setTimeout(() => cancel("timeout"), timeoutMs) : void 0;
436
+ try {
437
+ const result = await pendingAnswer;
438
+ settled = true;
439
+ return result;
440
+ } finally {
441
+ settled = true;
442
+ signal?.removeEventListener("abort", onAbort);
443
+ if (timeout) clearTimeout(timeout);
423
444
  }
424
- return result;
445
+ }
446
+ async resolveCancelledUnlessAnswered(questionId, reason) {
447
+ const latest = await this.store.getByQuestionId(questionId).catch(() => null);
448
+ if (latest?.status === "answered") return;
449
+ this.coordinator.resolveCancelled(questionId, reason);
425
450
  }
426
451
  async abandon(questionId, sessionId) {
427
452
  await this.store.markAbandoned(questionId);
@@ -433,7 +458,7 @@ var AskUserRuntime = class {
433
458
  return {
434
459
  questionId: randomUUID(),
435
460
  sessionId: request.sessionId,
436
- ownerPrincipalId: this.ownerPrincipalId,
461
+ ownerPrincipalId: request.ownerPrincipalId ?? this.ownerPrincipalId,
437
462
  status: "ready",
438
463
  title: request.title,
439
464
  context: request.context,
@@ -442,8 +467,7 @@ var AskUserRuntime = class {
442
467
  updatedAt: at
443
468
  };
444
469
  }
445
- assertAllowed(sessionId) {
446
- const principalId = this.ownerPrincipalId;
470
+ assertAllowed(sessionId, principalId) {
447
471
  if (!this.consume(this.sessionBuckets, sessionId, 6e4, this.perSessionPerMinute) || !this.consume(this.principalBuckets, principalId, 36e5, this.perPrincipalPerHour)) {
448
472
  throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.RATE_LIMITED, "ask_user rate limit exceeded");
449
473
  }
@@ -491,6 +515,7 @@ var FileAskUserStore = class {
491
515
  }
492
516
  filePath;
493
517
  state = null;
518
+ loadInFlight = null;
494
519
  writeChain = Promise.resolve();
495
520
  listeners = /* @__PURE__ */ new Set();
496
521
  async getPending(sessionId) {
@@ -501,15 +526,19 @@ var FileAskUserStore = class {
501
526
  if (!question || !isPending(question)) return null;
502
527
  return clone(question);
503
528
  }
529
+ async listPending() {
530
+ const state = await this.load();
531
+ return Object.values(state.pendingBySession).map((questionId) => state.questions[questionId]).filter(isPending).map((question) => clone(question));
532
+ }
504
533
  async getByQuestionId(questionId) {
505
534
  const state = await this.load();
506
535
  return state.questions[questionId] ? clone(state.questions[questionId]) : null;
507
536
  }
508
537
  async createPending(question) {
509
538
  await this.mutate(async (state) => {
510
- const existing = Object.values(state.pendingBySession).find((questionId) => isPending(state.questions[questionId]));
511
- if (existing) {
512
- throw new AskUserStoreError(ASK_USER_ERROR_CODES.PENDING_EXISTS, "a pending question already exists");
539
+ const existing = state.pendingBySession[question.sessionId];
540
+ if (existing && isPending(state.questions[existing])) {
541
+ throw new AskUserStoreError(ASK_USER_ERROR_CODES.PENDING_EXISTS, "a pending question already exists for this session");
513
542
  }
514
543
  state.questions[question.questionId] = clone(question);
515
544
  if (isPending(question)) state.pendingBySession[question.sessionId] = question.questionId;
@@ -593,14 +622,21 @@ var FileAskUserStore = class {
593
622
  }
594
623
  async load() {
595
624
  if (this.state) return this.state;
596
- try {
597
- const raw = await readFile(this.filePath, "utf8");
598
- this.state = { ...clone(EMPTY_STATE), ...JSON.parse(raw) };
599
- } catch (error) {
600
- if (error.code !== "ENOENT") throw error;
601
- this.state = clone(EMPTY_STATE);
625
+ if (!this.loadInFlight) {
626
+ this.loadInFlight = (async () => {
627
+ try {
628
+ const raw = await readFile(this.filePath, "utf8");
629
+ this.state = { ...clone(EMPTY_STATE), ...JSON.parse(raw) };
630
+ } catch (error) {
631
+ if (error.code !== "ENOENT") throw error;
632
+ this.state = clone(EMPTY_STATE);
633
+ }
634
+ return this.state;
635
+ })().finally(() => {
636
+ this.loadInFlight = null;
637
+ });
602
638
  }
603
- return this.state;
639
+ return this.loadInFlight;
604
640
  }
605
641
  async save(state) {
606
642
  await mkdir(dirname(this.filePath), { recursive: true });
@@ -665,27 +701,74 @@ var AskUserStatePublisher = class {
665
701
  store;
666
702
  bridge;
667
703
  unsubscribe;
704
+ publishChain = Promise.resolve();
705
+ hintsBySession = /* @__PURE__ */ new Map();
668
706
  start() {
669
707
  if (this.unsubscribe) return this.unsubscribe;
670
708
  this.unsubscribe = this.store.subscribe((change) => {
671
- void this.publishSession(change.sessionId);
709
+ void this.enqueuePublishSession(change.sessionId);
672
710
  });
711
+ void this.enqueueInitializeFromStore().catch(() => void 0);
673
712
  return () => this.stop();
674
713
  }
675
714
  stop() {
676
715
  this.unsubscribe?.();
677
716
  this.unsubscribe = void 0;
717
+ this.publishChain = Promise.resolve();
718
+ this.hintsBySession.clear();
678
719
  }
679
720
  async publishSession(sessionId) {
680
- const question = await this.store.getPending(sessionId);
721
+ const hint = toPendingHint(await this.store.getPending(sessionId));
681
722
  const current = await this.bridge.getState() ?? {};
723
+ if (hint) this.hintsBySession.set(sessionId, hint);
724
+ else this.hintsBySession.delete(sessionId);
682
725
  const next = {
683
726
  ...current,
684
- [ASK_USER_UI_STATE_SLOTS.PENDING]: { question }
727
+ [ASK_USER_UI_STATE_SLOTS.PENDING]: this.currentPendingState(hint)
685
728
  };
686
729
  await this.bridge.setState(next);
687
730
  }
731
+ currentPendingState(preferredHint) {
732
+ const hintsBySession = Object.fromEntries(this.hintsBySession.entries());
733
+ return {
734
+ hint: preferredHint ?? Object.values(hintsBySession)[0] ?? null,
735
+ hintsBySession
736
+ };
737
+ }
738
+ enqueuePublishSession(sessionId) {
739
+ return this.enqueuePublish(() => this.publishSession(sessionId));
740
+ }
741
+ enqueueInitializeFromStore() {
742
+ return this.enqueuePublish(() => this.initializeFromStore());
743
+ }
744
+ enqueuePublish(run) {
745
+ const next = this.publishChain.catch(() => void 0).then(run);
746
+ this.publishChain = next.catch(() => void 0);
747
+ return next;
748
+ }
749
+ async initializeFromStore() {
750
+ this.hintsBySession.clear();
751
+ for (const question of await this.store.listPending()) {
752
+ const hint = toPendingHint(question);
753
+ if (hint) this.hintsBySession.set(hint.sessionId, hint);
754
+ }
755
+ const current = await this.bridge.getState() ?? {};
756
+ const nextPending = this.currentPendingState();
757
+ if (JSON.stringify(current[ASK_USER_UI_STATE_SLOTS.PENDING]) === JSON.stringify(nextPending)) return;
758
+ await this.bridge.setState({
759
+ ...current,
760
+ [ASK_USER_UI_STATE_SLOTS.PENDING]: nextPending
761
+ });
762
+ }
688
763
  };
764
+ function toPendingHint(question) {
765
+ if (!question) return null;
766
+ return {
767
+ questionId: question.questionId,
768
+ sessionId: question.sessionId,
769
+ status: question.status
770
+ };
771
+ }
689
772
 
690
773
  // src/server/createAskUserTool.ts
691
774
  function createAskUserTool(options) {
@@ -759,7 +842,7 @@ function resolveSessionId(sessionId) {
759
842
  function formatAskUserResult(result) {
760
843
  if (result.status === "answered") {
761
844
  return {
762
- content: [{ type: "text", text: `User answered: ${JSON.stringify(result.answer.values)}` }],
845
+ content: [{ type: "text", text: `User answered: ${JSON.stringify(result.answer.values)}. Continue the conversation using this answer.` }],
763
846
  details: result
764
847
  };
765
848
  }
@@ -770,6 +853,29 @@ function formatAskUserResult(result) {
770
853
  };
771
854
  }
772
855
 
856
+ // src/server/askUserBridgeHandlers.ts
857
+ import {
858
+ WorkspaceBridgeErrorCode,
859
+ createWorkspaceBridgeError,
860
+ defineTrustedDomainBridgeHandler
861
+ } from "@hachej/boring-workspace/server";
862
+
863
+ // src/shared/bridge.ts
864
+ var ASK_USER_BRIDGE_OPS = {
865
+ request: "ask-user.v1.request",
866
+ answer: "ask-user.v1.answer",
867
+ cancel: "ask-user.v1.cancel",
868
+ pending: "ask-user.v1.pending",
869
+ transcript: "ask-user.v1.transcript"
870
+ };
871
+ var ASK_USER_BRIDGE_CAPABILITIES = {
872
+ request: "ask-user:request",
873
+ answer: "ask-user:answer",
874
+ cancel: "ask-user:cancel",
875
+ pending: "ask-user:pending",
876
+ transcriptRead: "ask-user:transcript.read"
877
+ };
878
+
773
879
  // src/server/questionsBridge.ts
774
880
  import { timingSafeEqual } from "crypto";
775
881
  var QuestionsBridgeError = class extends Error {
@@ -827,7 +933,7 @@ var QuestionsBridge = class {
827
933
  if (question.sessionId !== browserSessionId || auth.sessionId !== browserSessionId) {
828
934
  throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.SESSION_MISMATCH, "session mismatch", 403);
829
935
  }
830
- if (question.ownerPrincipalId !== auth.principalId) {
936
+ if (question.ownerPrincipalId !== "anonymous" && question.ownerPrincipalId !== auth.principalId) {
831
937
  throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.UNAUTHORIZED, "principal mismatch", 403);
832
938
  }
833
939
  }
@@ -894,58 +1000,268 @@ function isCode(error, code) {
894
1000
  return !!error && typeof error === "object" && "code" in error && error.code === code;
895
1001
  }
896
1002
 
897
- // src/server/questionsRoutes.ts
898
- function questionsRoutes(app, opts, done) {
899
- app.post("/api/v1/questions/commands", async (request, reply) => {
900
- if (!passesOrigin(request, opts.allowedOrigins)) return reply.code(403).send({ error: "forbidden", message: "invalid origin" });
901
- if (!await passesCsrf(request, opts)) return reply.code(403).send({ error: "forbidden", message: "invalid csrf token" });
902
- const parsed = QuestionsCommandSchema.safeParse(request.body);
903
- if (!parsed.success) {
904
- return reply.code(400).send({ error: "validation_error", message: parsed.error.issues[0]?.message ?? "invalid command" });
1003
+ // src/server/askUserBridgeHandlers.ts
1004
+ var MAX_QUESTION_BYTES = 64 * 1024;
1005
+ var MAX_TRANSCRIPT_BYTES = 256 * 1024;
1006
+ var REQUEST_TIMEOUT_MS = 10 * 6e4;
1007
+ var MUTATION_TIMEOUT_MS = 3e4;
1008
+ var READ_TIMEOUT_MS = 1e4;
1009
+ function createAskUserBridgeHandlers(options) {
1010
+ return [
1011
+ contribution(defineTrustedDomainBridgeHandler({
1012
+ op: ASK_USER_BRIDGE_OPS.request,
1013
+ version: 1,
1014
+ owner: ASK_USER_PLUGIN_ID,
1015
+ callerClassesAllowed: ["runtime", "server"],
1016
+ requiredCapabilities: [ASK_USER_BRIDGE_CAPABILITIES.request],
1017
+ inputSchema: { type: "object" },
1018
+ outputSchema: { type: "object" },
1019
+ timeoutMs: REQUEST_TIMEOUT_MS,
1020
+ maxInputBytes: MAX_QUESTION_BYTES,
1021
+ maxOutputBytes: MAX_QUESTION_BYTES,
1022
+ idempotencyPolicy: "request-id",
1023
+ handler: requestHandler(options)
1024
+ })),
1025
+ contribution(defineTrustedDomainBridgeHandler({
1026
+ op: ASK_USER_BRIDGE_OPS.answer,
1027
+ version: 1,
1028
+ owner: ASK_USER_PLUGIN_ID,
1029
+ callerClassesAllowed: ["browser", "server"],
1030
+ requiredCapabilities: [ASK_USER_BRIDGE_CAPABILITIES.answer],
1031
+ inputSchema: { type: "object" },
1032
+ outputSchema: { type: "object" },
1033
+ timeoutMs: MUTATION_TIMEOUT_MS,
1034
+ maxInputBytes: MAX_QUESTION_BYTES,
1035
+ maxOutputBytes: 1024,
1036
+ idempotencyPolicy: "required",
1037
+ handler: answerHandler(options)
1038
+ })),
1039
+ contribution(defineTrustedDomainBridgeHandler({
1040
+ op: ASK_USER_BRIDGE_OPS.cancel,
1041
+ version: 1,
1042
+ owner: ASK_USER_PLUGIN_ID,
1043
+ callerClassesAllowed: ["browser", "server"],
1044
+ requiredCapabilities: [ASK_USER_BRIDGE_CAPABILITIES.cancel],
1045
+ inputSchema: { type: "object" },
1046
+ outputSchema: { type: "object" },
1047
+ timeoutMs: MUTATION_TIMEOUT_MS,
1048
+ maxInputBytes: MAX_QUESTION_BYTES,
1049
+ maxOutputBytes: 1024,
1050
+ idempotencyPolicy: "required",
1051
+ handler: cancelHandler(options)
1052
+ })),
1053
+ contribution(defineTrustedDomainBridgeHandler({
1054
+ op: ASK_USER_BRIDGE_OPS.pending,
1055
+ version: 1,
1056
+ owner: ASK_USER_PLUGIN_ID,
1057
+ callerClassesAllowed: ["browser", "server"],
1058
+ requiredCapabilities: [ASK_USER_BRIDGE_CAPABILITIES.pending],
1059
+ inputSchema: { type: "object" },
1060
+ outputSchema: { type: "object" },
1061
+ timeoutMs: READ_TIMEOUT_MS,
1062
+ maxInputBytes: 1024,
1063
+ maxOutputBytes: MAX_QUESTION_BYTES,
1064
+ idempotencyPolicy: "none",
1065
+ handler: pendingHandler(options)
1066
+ })),
1067
+ contribution(defineTrustedDomainBridgeHandler({
1068
+ op: ASK_USER_BRIDGE_OPS.transcript,
1069
+ version: 1,
1070
+ owner: ASK_USER_PLUGIN_ID,
1071
+ callerClassesAllowed: ["server"],
1072
+ requiredCapabilities: [ASK_USER_BRIDGE_CAPABILITIES.transcriptRead],
1073
+ inputSchema: { type: "object" },
1074
+ outputSchema: { type: "object" },
1075
+ timeoutMs: READ_TIMEOUT_MS,
1076
+ maxInputBytes: 1024,
1077
+ maxOutputBytes: MAX_TRANSCRIPT_BYTES,
1078
+ idempotencyPolicy: "none",
1079
+ handler: transcriptHandler(options)
1080
+ }))
1081
+ ];
1082
+ }
1083
+ function contribution(entry) {
1084
+ return {
1085
+ definition: entry.definition,
1086
+ handler: entry.handler
1087
+ };
1088
+ }
1089
+ function requestHandler({ runtime }) {
1090
+ return async ({ input, context, signal }) => {
1091
+ assertRequestInput(input);
1092
+ assertRequestSessionScope(input.sessionId, context);
1093
+ try {
1094
+ return await runtime.ask({
1095
+ sessionId: input.sessionId,
1096
+ title: input.title,
1097
+ context: input.context,
1098
+ schema: input.schema,
1099
+ timeoutMs: input.timeoutMs,
1100
+ ownerPrincipalId: ownerPrincipalIdFromRuntimeContext(context)
1101
+ }, signal);
1102
+ } catch (error) {
1103
+ throw mapAskUserError(error);
905
1104
  }
906
- const bridge = new QuestionsBridge({
907
- store: opts.store,
908
- runtime: opts.runtime,
909
- getAuthContext: opts.getAuthContext ? () => opts.getAuthContext(request) : void 0
1105
+ };
1106
+ }
1107
+ function answerHandler(options) {
1108
+ return async ({ input, context }) => {
1109
+ assertAnswerInput(input);
1110
+ assertBrowserSessionScope(input.sessionId, context);
1111
+ return await runQuestionsBridge(options, context, {
1112
+ kind: "questions.submit",
1113
+ params: {
1114
+ questionId: input.questionId,
1115
+ sessionId: input.sessionId,
1116
+ answerToken: input.answerToken,
1117
+ values: input.values
1118
+ }
910
1119
  });
1120
+ };
1121
+ }
1122
+ function cancelHandler(options) {
1123
+ return async ({ input, context }) => {
1124
+ assertCancelInput(input);
1125
+ assertBrowserSessionScope(input.sessionId, context);
1126
+ return await runQuestionsBridge(options, context, {
1127
+ kind: "questions.cancel",
1128
+ params: {
1129
+ questionId: input.questionId,
1130
+ sessionId: input.sessionId,
1131
+ answerToken: input.answerToken
1132
+ }
1133
+ });
1134
+ };
1135
+ }
1136
+ function pendingHandler({ store }) {
1137
+ return async ({ input, context }) => {
1138
+ if (!input || typeof input.sessionId !== "string" || input.sessionId.length === 0) {
1139
+ throw invalid2("ask-user pending requires sessionId");
1140
+ }
1141
+ assertBrowserSessionScope(input.sessionId, context);
911
1142
  try {
912
- return await bridge.handle(parsed.data);
1143
+ const pending = await store.getPending(input.sessionId);
1144
+ assertQuestionOwner(context, pending);
1145
+ return { pending };
913
1146
  } catch (error) {
914
- return sendError(reply, error);
1147
+ throw mapAskUserError(error);
915
1148
  }
1149
+ };
1150
+ }
1151
+ function transcriptHandler({ store }) {
1152
+ return async ({ input }) => {
1153
+ if (!input || typeof input.sessionId !== "string" || input.sessionId.length === 0) {
1154
+ throw invalid2("ask-user transcript requires sessionId");
1155
+ }
1156
+ try {
1157
+ return { events: await store.listTranscriptEvents(input.sessionId) };
1158
+ } catch (error) {
1159
+ throw mapAskUserError(error);
1160
+ }
1161
+ };
1162
+ }
1163
+ async function runQuestionsBridge({ runtime, store }, context, command) {
1164
+ const auth = commandAuthSessionId(command.params.sessionId, context);
1165
+ const bridge = new QuestionsBridge({
1166
+ runtime,
1167
+ store,
1168
+ getAuthContext: () => auth
916
1169
  });
917
- done();
1170
+ try {
1171
+ return await bridge.handle(command);
1172
+ } catch (error) {
1173
+ throw mapAskUserError(error);
1174
+ }
918
1175
  }
919
- function passesOrigin(request, allowedOrigins) {
920
- if (!allowedOrigins || allowedOrigins.length === 0) return true;
921
- const origin = request.headers.origin;
922
- return typeof origin === "string" && allowedOrigins.includes(origin);
1176
+ function assertRequestInput(input) {
1177
+ if (!input || typeof input !== "object") throw invalid2("ask-user request input is required");
1178
+ if (typeof input.sessionId !== "string" || input.sessionId.length === 0) throw invalid2("ask-user request requires sessionId");
1179
+ if (!input.schema || typeof input.schema !== "object") throw invalid2("ask-user request requires schema");
1180
+ if (input.title !== void 0 && typeof input.title !== "string") throw invalid2("ask-user request title must be a string");
1181
+ if (input.context !== void 0 && typeof input.context !== "string") throw invalid2("ask-user request context must be a string");
1182
+ if (input.timeoutMs !== void 0 && (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)) throw invalid2("ask-user request timeoutMs must be positive");
923
1183
  }
924
- async function passesCsrf(request, opts) {
925
- if (!opts.csrfToken) return true;
926
- const headerName = (opts.csrfHeaderName ?? "x-csrf-token").toLowerCase();
927
- const actual = request.headers[headerName];
928
- const expected = typeof opts.csrfToken === "function" ? await opts.csrfToken(request) : opts.csrfToken;
929
- return typeof actual === "string" && typeof expected === "string" && constantTimeEqual(expected, actual);
1184
+ function assertAnswerInput(input) {
1185
+ assertMutationBase(input, "answer");
1186
+ if (!input.values || typeof input.values !== "object" || Array.isArray(input.values)) throw invalid2("ask-user answer requires values");
930
1187
  }
931
- function sendError(reply, error) {
1188
+ function assertCancelInput(input) {
1189
+ assertMutationBase(input, "cancel");
1190
+ }
1191
+ function assertMutationBase(input, op) {
1192
+ if (!input || typeof input !== "object") throw invalid2(`ask-user ${op} input is required`);
1193
+ if (typeof input.questionId !== "string" || input.questionId.length === 0) throw invalid2(`ask-user ${op} requires questionId`);
1194
+ if (typeof input.sessionId !== "string" || input.sessionId.length === 0) throw invalid2(`ask-user ${op} requires sessionId`);
1195
+ if (typeof input.answerToken !== "string" || input.answerToken.length === 0) throw invalid2(`ask-user ${op} requires answerToken`);
1196
+ }
1197
+ function assertBrowserSessionScope(sessionId, context) {
1198
+ if (context.callerClass !== "browser") return;
1199
+ if (context.sessionId && context.sessionId === sessionId) return;
1200
+ throw createWorkspaceBridgeError(
1201
+ WorkspaceBridgeErrorCode.ResourceScopeDenied,
1202
+ "ask-user session does not match browser bridge context"
1203
+ );
1204
+ }
1205
+ function assertRequestSessionScope(sessionId, context) {
1206
+ if (context.callerClass !== "runtime") return;
1207
+ if (context.sessionId && context.sessionId === sessionId) return;
1208
+ throw createWorkspaceBridgeError(
1209
+ WorkspaceBridgeErrorCode.ResourceScopeDenied,
1210
+ "ask-user request session does not match runtime bridge context"
1211
+ );
1212
+ }
1213
+ function assertQuestionOwner(context, question) {
1214
+ if (!question || context.callerClass !== "browser") return;
1215
+ if (question.ownerPrincipalId === "anonymous") return;
1216
+ const principalId = context.actor.performedBy?.id;
1217
+ if (!principalId || principalId !== question.ownerPrincipalId) {
1218
+ throw createWorkspaceBridgeError(
1219
+ WorkspaceBridgeErrorCode.ResourceScopeDenied,
1220
+ "ask-user question is not owned by this browser principal"
1221
+ );
1222
+ }
1223
+ }
1224
+ function commandAuthSessionId(sessionId, context) {
1225
+ if (context.callerClass !== "browser") {
1226
+ return { sessionId, principalId: principalIdFromContext(context) };
1227
+ }
1228
+ assertBrowserSessionScope(sessionId, context);
1229
+ return { sessionId: context.sessionId, principalId: principalIdFromContext(context) };
1230
+ }
1231
+ function principalIdFromContext(context) {
1232
+ return context.actor.performedBy?.id ?? context.actor.onBehalfOf?.id ?? "anonymous";
1233
+ }
1234
+ function ownerPrincipalIdFromRuntimeContext(context) {
1235
+ return context.actor.onBehalfOf?.id;
1236
+ }
1237
+ function mapAskUserError(error) {
932
1238
  if (error instanceof QuestionsBridgeError) {
933
- return reply.code(error.statusCode).send({ error: error.code, message: error.message });
1239
+ const code = error.statusCode === 403 ? WorkspaceBridgeErrorCode.ResourceScopeDenied : WorkspaceBridgeErrorCode.InvalidRequest;
1240
+ throw createWorkspaceBridgeError(code, error.message, { askUserCode: error.code });
1241
+ }
1242
+ if (error instanceof AskUserRuntimeError || error instanceof AskUserStoreError) {
1243
+ const code = error.code === ASK_USER_ERROR_CODES.RATE_LIMITED ? WorkspaceBridgeErrorCode.CapabilityDenied : WorkspaceBridgeErrorCode.InvalidRequest;
1244
+ throw createWorkspaceBridgeError(code, error.message, { askUserCode: error.code });
934
1245
  }
935
1246
  throw error;
936
1247
  }
1248
+ function invalid2(message) {
1249
+ throw createWorkspaceBridgeError(WorkspaceBridgeErrorCode.InvalidRequest, message);
1250
+ }
937
1251
 
938
1252
  // src/server/askUserServerPlugin.ts
939
1253
  function createAskUserServerPlugin(options) {
1254
+ if (options.routes) {
1255
+ throw new Error("createAskUserServerPlugin no longer registers /api/v1/questions/commands; use WorkspaceBridge ask-user.v1.* handlers or import questionsRoutes for manual legacy wiring");
1256
+ }
940
1257
  const store = options.store ?? createDefaultStore(options.workspaceRoot);
941
1258
  const runtime = options.runtime ?? new AskUserRuntime({ store, uiBridge: options.bridge });
942
1259
  const stopPublisher = options.bridge ? new AskUserStatePublisher(store, options.bridge).start() : void 0;
943
- const routes = async (app) => {
1260
+ const lifecycle = async (app) => {
944
1261
  app.addHook("onClose", async () => {
945
1262
  stopPublisher?.();
946
1263
  options.onClose?.();
947
1264
  });
948
- await app.register(questionsRoutes, { ...defaultRoutes, ...options.routes, runtime, store });
949
1265
  };
950
1266
  const askUserTool = createAskUserTool({ runtime, sessionId: options.sessionId ?? (() => "default") });
951
1267
  return defineServerPlugin({
@@ -961,28 +1277,57 @@ function createAskUserServerPlugin(options) {
961
1277
  return askUserTool.execute(ctx.toolCallId, params, ctx.abortSignal, ctx.sessionId);
962
1278
  }
963
1279
  }],
964
- routes,
1280
+ workspaceBridgeHandlers: createAskUserBridgeHandlers({ runtime, store }),
1281
+ routes: lifecycle,
965
1282
  preservedUiStateKeys: [ASK_USER_UI_STATE_SLOTS.PENDING]
966
1283
  });
967
1284
  }
968
- var defaultRoutes = {
969
- // No-auth playground/default shells still need the browser command channel to
970
- // bind to the question's owning session. The answerToken remains the terminal
971
- // mutation secret; this context only prevents the default anonymous session
972
- // sentinel from rejecting legitimate no-auth submits as SESSION_MISMATCH.
973
- getAuthContext: (request) => {
974
- const body = request.body;
975
- return {
976
- sessionId: typeof body?.params?.sessionId === "string" ? body.params.sessionId : "anonymous",
977
- principalId: "anonymous"
978
- };
979
- }
980
- };
981
1285
  function createDefaultStore(workspaceRoot) {
982
1286
  if (!workspaceRoot) throw new Error("createAskUserServerPlugin requires workspaceRoot when store is not provided");
983
1287
  return new FileAskUserStore(join2(workspaceRoot, ".boring", "ask-user.json"));
984
1288
  }
985
1289
 
1290
+ // src/server/questionsRoutes.ts
1291
+ function questionsRoutes(app, opts, done) {
1292
+ app.post("/api/v1/questions/commands", async (request, reply) => {
1293
+ if (!passesOrigin(request, opts.allowedOrigins)) return reply.code(403).send({ error: "forbidden", message: "invalid origin" });
1294
+ if (!await passesCsrf(request, opts)) return reply.code(403).send({ error: "forbidden", message: "invalid csrf token" });
1295
+ const parsed = QuestionsCommandSchema.safeParse(request.body);
1296
+ if (!parsed.success) {
1297
+ return reply.code(400).send({ error: "validation_error", message: parsed.error.issues[0]?.message ?? "invalid command" });
1298
+ }
1299
+ const bridge = new QuestionsBridge({
1300
+ store: opts.store,
1301
+ runtime: opts.runtime,
1302
+ getAuthContext: opts.getAuthContext ? () => opts.getAuthContext(request) : void 0
1303
+ });
1304
+ try {
1305
+ return await bridge.handle(parsed.data);
1306
+ } catch (error) {
1307
+ return sendError(reply, error);
1308
+ }
1309
+ });
1310
+ done();
1311
+ }
1312
+ function passesOrigin(request, allowedOrigins) {
1313
+ if (!allowedOrigins || allowedOrigins.length === 0) return true;
1314
+ const origin = request.headers.origin;
1315
+ return typeof origin === "string" && allowedOrigins.includes(origin);
1316
+ }
1317
+ async function passesCsrf(request, opts) {
1318
+ if (!opts.csrfToken) return true;
1319
+ const headerName = (opts.csrfHeaderName ?? "x-csrf-token").toLowerCase();
1320
+ const actual = request.headers[headerName];
1321
+ const expected = typeof opts.csrfToken === "function" ? await opts.csrfToken(request) : opts.csrfToken;
1322
+ return typeof actual === "string" && typeof expected === "string" && constantTimeEqual(expected, actual);
1323
+ }
1324
+ function sendError(reply, error) {
1325
+ if (error instanceof QuestionsBridgeError) {
1326
+ return reply.code(error.statusCode).send({ error: error.code, message: error.message });
1327
+ }
1328
+ throw error;
1329
+ }
1330
+
986
1331
  // src/server/index.ts
987
1332
  function defaultAskUserServerPlugin(options, ctx) {
988
1333
  return createAskUserServerPlugin({
@@ -1002,6 +1347,7 @@ export {
1002
1347
  QuestionsBridge,
1003
1348
  QuestionsBridgeError,
1004
1349
  constantTimeEqual,
1350
+ createAskUserBridgeHandlers,
1005
1351
  createAskUserServerPlugin,
1006
1352
  createAskUserTool,
1007
1353
  defaultAskUserServerPlugin as default,