dsh-realtime-audio-ws 0.2.1 → 0.2.2

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 +1 @@
1
- {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAG3C;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,WAAW,CAAC,CAAA;AAEjF,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,wDAAwD;IACxD,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;KAAE,KAAK,GAAG,GAAG,GAAG,GAAG,SAAS,CAAA;IAClG,4BAA4B;IAC5B,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAA;CACrC;AAED,0GAA0G;AAC1G,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,IAAI,CAAA;CACtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,gBAAgB,CAmBxE"}
1
+ {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAG3C;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,GAAG,WAAW,CAAC,CAAA;AAEjF,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,wDAAwD;IACxD,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;KAAE,KAAK,GAAG,GAAG,GAAG,GAAG,SAAS,CAAA;IAClG,4BAA4B;IAC5B,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAA;CACrC;AAED,0GAA0G;AAC1G,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,IAAI,CAAA;CACtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,gBAAgB,CAgCxE"}
@@ -35,21 +35,58 @@ export function diagnosticsRoute(deps) {
35
35
  kind: 'exact',
36
36
  path: deps.path,
37
37
  handler: (req, res) => {
38
+ // The host's own page is cross-origin to loopback, and a browser discards a cross-origin response
39
+ // that carries no `Access-Control-Allow-Origin` — the request authenticates, the route answers
40
+ // 200, and the caller sees a CORS failure with no body. The WebSocket sibling is exempt from CORS,
41
+ // which is how this went unnoticed: one policy, two doors, and only one of them told the browser
42
+ // it was allowed to read the answer.
43
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
38
44
  const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token);
39
45
  if (rejection !== undefined) {
40
46
  // The status carries the verdict the connection service actually gave, so a reader can tell a
41
47
  // missing credential from a refused one rather than guessing from a generic failure.
42
- send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
48
+ send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' }, origin);
49
+ return;
50
+ }
51
+ // A preflight is answered only after the verdict, never before it. Answering one unauthenticated
52
+ // would confirm the route's existence to a caller holding no capability at all.
53
+ if (req.method === 'OPTIONS') {
54
+ res.writeHead(204, headers(origin));
55
+ res.end();
43
56
  return;
44
57
  }
45
58
  const entries = deps.journal.snapshot();
46
59
  // `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
47
60
  // a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
48
61
  // number is the only thing that separates "nothing happened" from "the buffer rolled over".
49
- send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries });
62
+ send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries }, origin);
50
63
  },
51
64
  };
52
65
  }
66
+ /**
67
+ * The headers every response from this route carries.
68
+ *
69
+ * `Access-Control-Allow-Origin` **reflects the caller's own origin** rather than naming one. The token is
70
+ * the authorisation here, not the origin: a request only reaches this point holding a capability that
71
+ * exists nowhere but the page the host injected it into, so echoing the origin grants nothing the token
72
+ * has not already granted — and it keeps the route readable from a profile whose page is served on some
73
+ * other scheme. `Vary: Origin`, because the header varies with the request and a cache that missed that
74
+ * would serve one origin's answer to another.
75
+ * @param origin - the request's `Origin`, when it sent one.
76
+ * @returns the response headers.
77
+ */
78
+ function headers(origin) {
79
+ const built = {
80
+ 'content-type': 'application/json',
81
+ 'cache-control': 'no-store',
82
+ vary: 'Origin',
83
+ };
84
+ // Absent when the caller sent no origin — a command-line client needs no permission to read a response
85
+ // it was never going to have a browser apply a same-origin policy to.
86
+ if (origin !== undefined)
87
+ built['access-control-allow-origin'] = origin;
88
+ return built;
89
+ }
53
90
  /**
54
91
  * Write one JSON response.
55
92
  *
@@ -58,9 +95,10 @@ export function diagnosticsRoute(deps) {
58
95
  * @param res - the response this handler owns.
59
96
  * @param status - HTTP status to write.
60
97
  * @param body - value to serialise as the JSON body.
98
+ * @param origin - the request's `Origin`, carried through so the answer is readable by whoever asked.
61
99
  */
62
- function send(res, status, body) {
63
- res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
100
+ function send(res, status, body, origin) {
101
+ res.writeHead(status, headers(origin));
64
102
  res.end(JSON.stringify(body));
65
103
  }
66
104
  //# sourceMappingURL=diagnostics.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"diagnostics.js","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AA8BzD;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqB;IACpD,OAAO;QACL,IAAI,EAAE,OAAO;QACb,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;YACpB,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;YACvF,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;gBAC5B,8FAA8F;gBAC9F,qFAAqF;gBACrF,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,KAAK,GAAG,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAA;gBACjF,OAAM;YACR,CAAC;YACD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAA;YACvC,iGAAiG;YACjG,iGAAiG;YACjG,4FAA4F;YAC5F,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,EAAE,CAAC,CAAA;QACzF,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,IAAI,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAa;IAC9D,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,eAAe,EAAE,UAAU,EAAE,CAAC,CAAA;IAC1F,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/B,CAAC"}
1
+ {"version":3,"file":"diagnostics.js","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AA8BzD;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqB;IACpD,OAAO;QACL,IAAI,EAAE,OAAO;QACb,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;YACpB,kGAAkG;YAClG,+FAA+F;YAC/F,mGAAmG;YACnG,iGAAiG;YACjG,qCAAqC;YACrC,MAAM,MAAM,GAAG,OAAO,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;YACtF,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;YACvF,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;gBAC5B,8FAA8F;gBAC9F,qFAAqF;gBACrF,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,KAAK,GAAG,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,MAAM,CAAC,CAAA;gBACzF,OAAM;YACR,CAAC;YACD,iGAAiG;YACjG,gFAAgF;YAChF,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC7B,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAA;gBACnC,GAAG,CAAC,GAAG,EAAE,CAAA;gBACT,OAAM;YACR,CAAC;YACD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAA;YACvC,iGAAiG;YACjG,iGAAiG;YACjG,4FAA4F;YAC5F,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,MAAM,CAAC,CAAA;QACjG,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,OAAO,CAAC,MAA0B;IACzC,MAAM,KAAK,GAA2B;QACpC,cAAc,EAAE,kBAAkB;QAClC,eAAe,EAAE,UAAU;QAC3B,IAAI,EAAE,QAAQ;KACf,CAAA;IACD,uGAAuG;IACvG,sEAAsE;IACtE,IAAI,MAAM,KAAK,SAAS;QAAE,KAAK,CAAC,6BAA6B,CAAC,GAAG,MAAM,CAAA;IACvE,OAAO,KAAK,CAAA;AACd,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,IAAI,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAa,EAAE,MAA0B;IAC1F,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAA;IACtC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -67,22 +67,59 @@ export function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute {
67
67
  kind: 'exact',
68
68
  path: deps.path,
69
69
  handler: (req, res) => {
70
+ // The host's own page is cross-origin to loopback, and a browser discards a cross-origin response
71
+ // that carries no `Access-Control-Allow-Origin` — the request authenticates, the route answers
72
+ // 200, and the caller sees a CORS failure with no body. The WebSocket sibling is exempt from CORS,
73
+ // which is how this went unnoticed: one policy, two doors, and only one of them told the browser
74
+ // it was allowed to read the answer.
75
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
70
76
  const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token)
71
77
  if (rejection !== undefined) {
72
78
  // The status carries the verdict the connection service actually gave, so a reader can tell a
73
79
  // missing credential from a refused one rather than guessing from a generic failure.
74
- send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
80
+ send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' }, origin)
81
+ return
82
+ }
83
+ // A preflight is answered only after the verdict, never before it. Answering one unauthenticated
84
+ // would confirm the route's existence to a caller holding no capability at all.
85
+ if (req.method === 'OPTIONS') {
86
+ res.writeHead(204, headers(origin))
87
+ res.end()
75
88
  return
76
89
  }
77
90
  const entries = deps.journal.snapshot()
78
91
  // `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
79
92
  // a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
80
93
  // number is the only thing that separates "nothing happened" from "the buffer rolled over".
81
- send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries })
94
+ send(res, 200, { size: deps.journal.size, oldestSeq: deps.journal.oldestSeq, entries }, origin)
82
95
  },
83
96
  }
84
97
  }
85
98
 
99
+ /**
100
+ * The headers every response from this route carries.
101
+ *
102
+ * `Access-Control-Allow-Origin` **reflects the caller's own origin** rather than naming one. The token is
103
+ * the authorisation here, not the origin: a request only reaches this point holding a capability that
104
+ * exists nowhere but the page the host injected it into, so echoing the origin grants nothing the token
105
+ * has not already granted — and it keeps the route readable from a profile whose page is served on some
106
+ * other scheme. `Vary: Origin`, because the header varies with the request and a cache that missed that
107
+ * would serve one origin's answer to another.
108
+ * @param origin - the request's `Origin`, when it sent one.
109
+ * @returns the response headers.
110
+ */
111
+ function headers(origin: string | undefined): Record<string, string> {
112
+ const built: Record<string, string> = {
113
+ 'content-type': 'application/json',
114
+ 'cache-control': 'no-store',
115
+ vary: 'Origin',
116
+ }
117
+ // Absent when the caller sent no origin — a command-line client needs no permission to read a response
118
+ // it was never going to have a browser apply a same-origin policy to.
119
+ if (origin !== undefined) built['access-control-allow-origin'] = origin
120
+ return built
121
+ }
122
+
86
123
  /**
87
124
  * Write one JSON response.
88
125
  *
@@ -91,8 +128,9 @@ export function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute {
91
128
  * @param res - the response this handler owns.
92
129
  * @param status - HTTP status to write.
93
130
  * @param body - value to serialise as the JSON body.
131
+ * @param origin - the request's `Origin`, carried through so the answer is readable by whoever asked.
94
132
  */
95
- function send(res: ServerResponse, status: number, body: unknown): void {
96
- res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
133
+ function send(res: ServerResponse, status: number, body: unknown, origin: string | undefined): void {
134
+ res.writeHead(status, headers(origin))
97
135
  res.end(JSON.stringify(body))
98
136
  }