dsh-realtime-audio-ws 0.2.0 → 0.2.1

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The diagnostics route: the journal, served as JSON over the web server's route registry.
3
+ *
4
+ * S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
5
+ * the whole cost this project kept paying, an evening spent inferring a reason that was never written
6
+ * down anywhere.
7
+ *
8
+ * ## The access policy is the audio route's, reused rather than re-derived
9
+ *
10
+ * The host's route registry "knows no harness concepts" and its handlers own the full response, so
11
+ * nothing upstream answers the authentication question for us. The audio route already answers it in
12
+ * this package: ask the connection service, and let the process's capability token override a refusal,
13
+ * because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
14
+ * — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
15
+ * the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
16
+ * one of them is found holding the weaker answer.
17
+ *
18
+ * ## What it deliberately does not do
19
+ *
20
+ * It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
21
+ * in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
22
+ * boundary would look safer and be strictly worse: the secret would already be retained, in memory and
23
+ * in whatever the process dumps.
24
+ *
25
+ * @module dsh-realtime-audio-ws/diagnostics
26
+ */
27
+ import type { IncomingMessage, ServerResponse } from 'node:http';
28
+ import type { Journal } from 'dsh-realtime';
29
+ /**
30
+ * The slice of the journal this route reads.
31
+ *
32
+ * Typed as the public surface rather than as the class: `Journal` exists as two declarations in this
33
+ * repository — built `lib/` and `src/` — and a class with a private member is nominally typed, so the
34
+ * two are not assignable to each other even though they are the same code.
35
+ */
36
+ export type DiagnosticsJournal = Pick<Journal, 'snapshot' | 'size' | 'oldestSeq'>;
37
+ /** What the route needs from the plugin that owns it. */
38
+ export interface DiagnosticsDeps {
39
+ /** Absolute pathname to claim, no trailing slash — the registry's own contract. */
40
+ readonly path: string;
41
+ /** This process's capability token, the same one the audio route publishes to the page. */
42
+ readonly token: string;
43
+ /** The connection service's verdict for one request. */
44
+ readonly rejectionFor: (request: {
45
+ headers: IncomingMessage['headers'];
46
+ }) => 401 | 403 | undefined;
47
+ /** The journal to serve. */
48
+ readonly journal: DiagnosticsJournal;
49
+ }
50
+ /** A route the host's registry accepts, described structurally as this package describes the registry. */
51
+ export interface DiagnosticsRoute {
52
+ readonly kind: 'exact';
53
+ readonly path: string;
54
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => void;
55
+ }
56
+ /**
57
+ * Build the diagnostics route.
58
+ * @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
59
+ * @returns the route registration, ready for `webServer.register`.
60
+ */
61
+ export declare function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute;
62
+ //# sourceMappingURL=diagnostics.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The diagnostics route: the journal, served as JSON over the web server's route registry.
3
+ *
4
+ * S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
5
+ * the whole cost this project kept paying, an evening spent inferring a reason that was never written
6
+ * down anywhere.
7
+ *
8
+ * ## The access policy is the audio route's, reused rather than re-derived
9
+ *
10
+ * The host's route registry "knows no harness concepts" and its handlers own the full response, so
11
+ * nothing upstream answers the authentication question for us. The audio route already answers it in
12
+ * this package: ask the connection service, and let the process's capability token override a refusal,
13
+ * because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
14
+ * — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
15
+ * the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
16
+ * one of them is found holding the weaker answer.
17
+ *
18
+ * ## What it deliberately does not do
19
+ *
20
+ * It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
21
+ * in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
22
+ * boundary would look safer and be strictly worse: the secret would already be retained, in memory and
23
+ * in whatever the process dumps.
24
+ *
25
+ * @module dsh-realtime-audio-ws/diagnostics
26
+ */
27
+ import { tokenFromUrl, verdictFor } from './injection.js';
28
+ /**
29
+ * Build the diagnostics route.
30
+ * @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
31
+ * @returns the route registration, ready for `webServer.register`.
32
+ */
33
+ export function diagnosticsRoute(deps) {
34
+ return {
35
+ kind: 'exact',
36
+ path: deps.path,
37
+ handler: (req, res) => {
38
+ const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token);
39
+ if (rejection !== undefined) {
40
+ // The status carries the verdict the connection service actually gave, so a reader can tell a
41
+ // missing credential from a refused one rather than guessing from a generic failure.
42
+ send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' });
43
+ return;
44
+ }
45
+ const entries = deps.journal.snapshot();
46
+ // `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
47
+ // a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
48
+ // 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 });
50
+ },
51
+ };
52
+ }
53
+ /**
54
+ * Write one JSON response.
55
+ *
56
+ * `no-store` on both branches. This response is a picture of a moving buffer, and a cached one would be
57
+ * read as current — the same class of mistake as a journal that cannot show its own eviction.
58
+ * @param res - the response this handler owns.
59
+ * @param status - HTTP status to write.
60
+ * @param body - value to serialise as the JSON body.
61
+ */
62
+ function send(res, status, body) {
63
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
64
+ res.end(JSON.stringify(body));
65
+ }
66
+ //# sourceMappingURL=diagnostics.js.map
@@ -0,0 +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"}
package/lib/index.d.ts CHANGED
@@ -9,13 +9,26 @@
9
9
  * connection services exist, so a composition without a web stack gets a row that visibly waits rather
10
10
  * than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
11
11
  * the functional path against a real socket with both services present.
12
+ *
13
+ * It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
14
+ * that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
15
+ * why the second reuses the first's check rather than deriving its own.
12
16
  */
13
17
  import Schema from '@deepseek-ai/schemastery';
14
18
  import type { Context } from '@deepseek-ai/cordis';
15
19
  import { type RealtimeAudioWsConfig } from './types.ts';
16
20
  export * from './types.ts';
21
+ /**
22
+ * The query parameter carrying the capability token.
23
+ *
24
+ * Published with the route rather than kept internal: nothing can address the route without it, and a
25
+ * consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
26
+ * stays where it is.
27
+ */
28
+ export { TOKEN_PARAM } from './injection.ts';
17
29
  export { attachAudioSocket, toBytes, type AudioSocketBridgeDeps } from './bridge.ts';
18
30
  export { createUpgradeAcceptor, rejectUpgrade, type UpgradeAcceptor } from './upgrade.ts';
31
+ export { diagnosticsRoute, type DiagnosticsDeps, type DiagnosticsJournal, type DiagnosticsRoute } from './diagnostics.ts';
19
32
  /** Plugin name. */
20
33
  export declare const name = "realtime-audio-ws";
21
34
  export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
@@ -23,11 +36,13 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
23
36
  maxFrameBytes: Schema<number, number, "defined">;
24
37
  maxConnections: Schema<number, number, "defined">;
25
38
  openSessionOnConnect: Schema<boolean, boolean, "defined">;
39
+ diagnosticsPath: Schema<string, string, "defined">;
26
40
  }>>, Schemastery.ObjectT<NoInfer<{
27
41
  path: Schema<string, string, "defined">;
28
42
  maxFrameBytes: Schema<number, number, "defined">;
29
43
  maxConnections: Schema<number, number, "defined">;
30
44
  openSessionOnConnect: Schema<boolean, boolean, "defined">;
45
+ diagnosticsPath: Schema<string, string, "defined">;
31
46
  }>>, "plain">;
32
47
  export declare function apply(ctx: Context, config: RealtimeAudioWsConfig): void;
33
48
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAelD,OAAO,EAML,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,eAAO,MAAM,IAAI,sBAAsB,CAAA;AAEvC,eAAO,MAAM,MAAM;;;;;;;;;;aAQjB,CAAA;AA4BF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CA+EvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAgBlD,OAAO,EAOL,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B;;;;;;GAMG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AACzF,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,KAAK,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AAEzH,mBAAmB;AACnB,eAAO,MAAM,IAAI,sBAAsB,CAAA;AAEvC,eAAO,MAAM,MAAM;;;;;;;;;;;;aASjB,CAAA;AAqCF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CA6GvE"}
package/lib/index.js CHANGED
@@ -9,15 +9,29 @@
9
9
  * connection services exist, so a composition without a web stack gets a row that visibly waits rather
10
10
  * than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
11
11
  * the functional path against a real socket with both services present.
12
+ *
13
+ * It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
14
+ * that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
15
+ * why the second reuses the first's check rather than deriving its own.
12
16
  */
13
17
  import Schema from '@deepseek-ai/schemastery';
14
18
  import { attachAudioSocket } from './bridge.js';
19
+ import { diagnosticsRoute } from './diagnostics.js';
15
20
  import { createRouteToken, routeAuthority, routeInjectionRow, tokenFromUrl, verdictFor, } from './injection.js';
16
21
  import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
17
- import { DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
22
+ import { DEFAULT_DIAGNOSTICS_PATH, DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
18
23
  export * from './types.js';
24
+ /**
25
+ * The query parameter carrying the capability token.
26
+ *
27
+ * Published with the route rather than kept internal: nothing can address the route without it, and a
28
+ * consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
29
+ * stays where it is.
30
+ */
31
+ export { TOKEN_PARAM } from './injection.js';
19
32
  export { attachAudioSocket, toBytes } from './bridge.js';
20
33
  export { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
34
+ export { diagnosticsRoute } from './diagnostics.js';
21
35
  /** Plugin name. */
22
36
  export const name = 'realtime-audio-ws';
23
37
  export const Config = Schema.object({
@@ -28,13 +42,20 @@ export const Config = Schema.object({
28
42
  // boot with nobody asking, which is why it is false. This one opens a session because someone connected a
29
43
  // microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
30
44
  openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
45
+ diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
31
46
  });
32
47
  export function apply(ctx, config) {
33
- ctx.inject(['connection', 'webServer'], (injected) => {
48
+ ctx.inject(['connection', 'webServer', 'realtime'], (injected) => {
34
49
  const services = injected;
35
50
  const acceptor = createUpgradeAcceptor(config.maxFrameBytes);
36
51
  const clients = new Set();
37
52
  const token = createRouteToken();
53
+ const journal = injected.realtime.journal;
54
+ // The token travels in a URL query, and this route records request targets, so the journal has to be
55
+ // able to redact it before it records one. This is exactly why `addSecrets` is additive instead of a
56
+ // constructor argument: the plugin that mints the token is the only one that can name it, and it
57
+ // mints it here, after the journal already exists.
58
+ journal.addSecrets([token]);
38
59
  // The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
39
60
  // does not import: importing it would augment `Context` with a second declaration of `webServer` and
40
61
  // turn the structural dependency above into a compile error. The name is cast at this one boundary.
@@ -46,6 +67,12 @@ export function apply(ctx, config) {
46
67
  onInjection('webserver/index-inject', (table) => {
47
68
  table.push(routeInjectionRow(config.path, routeAuthority(services.webServer.config?.host, services.webServer.listenedPort), token));
48
69
  });
70
+ const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
71
+ path: config.diagnosticsPath,
72
+ token,
73
+ rejectionFor: (request) => services.connection.requestRejection(request),
74
+ journal,
75
+ }));
49
76
  const unregister = services.webServer.registerUpgrade({
50
77
  path: config.path,
51
78
  handler: (req, socket, head) => {
@@ -59,18 +86,34 @@ export function apply(ctx, config) {
59
86
  // from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
60
87
  // `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
61
88
  // correct the rest of the client half is.
89
+ // Normalised once, at the caller's edge, so no record below repeats the check. `node:http`
90
+ // always reports a target on a server request; the type admits `undefined` for client requests,
91
+ // which this handler cannot see.
92
+ const target = req.url ?? '';
62
93
  const rejection = verdictFor(services.connection.requestRejection(req), tokenFromUrl(req.url), token);
63
94
  if (rejection !== undefined) {
95
+ // The verdict only, and no target. A valid token is never rejected — it overrides the
96
+ // refusal — so any target recorded here belongs to something else, and a wrong token is
97
+ // still a credential. Retaining a caller's secret in order to log a refusal is a worse trade
98
+ // than losing which path was probed.
99
+ journal.record('socket.rejected', { verdict: String(rejection) });
64
100
  rejectUpgrade(socket, rejection);
65
101
  return;
66
102
  }
67
103
  acceptor.handleUpgrade(req, socket, head, (client) => {
68
104
  if (clients.size >= config.maxConnections) {
69
105
  // 1013 = try again later. Closing the newcomer leaves the existing microphone live.
106
+ journal.record('socket.rejected', { verdict: '1013', reason: 'busy' });
70
107
  client.close(1013, 'busy');
71
108
  return;
72
109
  }
73
110
  clients.add(client);
111
+ // The target here, because an accepted request is the one place this process's own token
112
+ // travels: the app page presents it in the query precisely because it cannot carry the
113
+ // cookie. The journal redacts it on write — which is what the token was added to the journal's
114
+ // secrets for, and the reason that has to be additive rather than a constructor argument,
115
+ // since the token does not exist until this plugin applies.
116
+ journal.record('socket.accepted', { clients: String(clients.size), url: target });
74
117
  // Ask for the session before the bridge goes in, so the first frames are written into a session
75
118
  // that is being opened rather than dropped by the mic seam's no-session rule.
76
119
  if (config.openSessionOnConnect)
@@ -81,6 +124,7 @@ export function apply(ctx, config) {
81
124
  maxFrameBytes: config.maxFrameBytes,
82
125
  onDetach: () => {
83
126
  clients.delete(client);
127
+ journal.record('socket.closed', { clients: String(clients.size) });
84
128
  // The last client leaving ends the session. This also covers the transport's own disposal,
85
129
  // which terminates its clients — so a profile reload does not leave a session nobody holds.
86
130
  if (config.openSessionOnConnect && clients.size === 0)
@@ -92,6 +136,7 @@ export function apply(ctx, config) {
92
136
  });
93
137
  return async () => {
94
138
  unregister();
139
+ unregisterDiagnostics();
95
140
  for (const client of clients)
96
141
  client.terminate();
97
142
  clients.clear();
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/lib/types.d.ts CHANGED
@@ -19,6 +19,14 @@ export declare const DEFAULT_MAX_FRAME_BYTES = 480000;
19
19
  export declare const DEFAULT_MAX_CONNECTIONS = 1;
20
20
  /** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
21
21
  export declare const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
22
+ /**
23
+ * Default pathname the diagnostics route claims.
24
+ *
25
+ * A fixed API path in spirit, but a validated config field like the audio route's — so a composition
26
+ * that already owns this path can move it deliberately rather than discovering the collision when the
27
+ * registry throws at registration.
28
+ */
29
+ export declare const DEFAULT_DIAGNOSTICS_PATH = "/dsh-realtime/diagnostics";
22
30
  export interface RealtimeAudioWsConfig {
23
31
  /**
24
32
  * Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
@@ -45,6 +53,14 @@ export interface RealtimeAudioWsConfig {
45
53
  * that looks like a fault anywhere but in this file.
46
54
  */
47
55
  readonly openSessionOnConnect: boolean;
56
+ /**
57
+ * Absolute pathname the diagnostics route claims, served as JSON.
58
+ *
59
+ * Authenticated by the same policy as the audio route, and for the same reason: it is an HTTP route on
60
+ * loopback that the host's registry does not gate for us, and it reports the journal — which is worth
61
+ * more to a stranger than an empty socket.
62
+ */
63
+ readonly diagnosticsPath: string;
48
64
  }
49
65
  /**
50
66
  * The slice of a WebSocket this package uses, described structurally.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;CACvC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
package/lib/types.js CHANGED
@@ -19,4 +19,12 @@ export const DEFAULT_MAX_FRAME_BYTES = 480_000;
19
19
  export const DEFAULT_MAX_CONNECTIONS = 1;
20
20
  /** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
21
21
  export const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
22
+ /**
23
+ * Default pathname the diagnostics route claims.
24
+ *
25
+ * A fixed API path in spirit, but a validated config field like the audio route's — so a composition
26
+ * that already owns this path can move it deliberately rather than discovering the collision when the
27
+ * registry throws at registration.
28
+ */
29
+ export const DEFAULT_DIAGNOSTICS_PATH = '/dsh-realtime/diagnostics';
22
30
  //# sourceMappingURL=types.js.map
package/lib/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC,0GAA0G;AAC1G,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAA"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC,0GAA0G;AAC1G,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAA;AAEnD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,2BAA2B,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "ws": "^8.22.0",
55
- "dsh-realtime-agent": "^0.2.2"
55
+ "dsh-realtime-agent": "^0.2.6"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@deepseek-ai/cordis": "^4.0.3",
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The diagnostics route: the journal, served as JSON over the web server's route registry.
3
+ *
4
+ * S1 story 4. It exists so a failure can be read *after* the fact without a console attached — which is
5
+ * the whole cost this project kept paying, an evening spent inferring a reason that was never written
6
+ * down anywhere.
7
+ *
8
+ * ## The access policy is the audio route's, reused rather than re-derived
9
+ *
10
+ * The host's route registry "knows no harness concepts" and its handlers own the full response, so
11
+ * nothing upstream answers the authentication question for us. The audio route already answers it in
12
+ * this package: ask the connection service, and let the process's capability token override a refusal,
13
+ * because the desktop app's page is served from `dsh-app://app` and is therefore cross-site to loopback
14
+ * — it can never carry the harness's `SameSite=Strict` cookie. This route asks in the same position with
15
+ * the same token: one policy, two doors. Inventing a second check here is how two schemes drift until
16
+ * one of them is found holding the weaker answer.
17
+ *
18
+ * ## What it deliberately does not do
19
+ *
20
+ * It does not redact on the way out. The journal redacts on **write**, so there is no unredacted value
21
+ * in the buffer to serve and no second step for a later edit to forget. A route that redacted at the
22
+ * boundary would look safer and be strictly worse: the secret would already be retained, in memory and
23
+ * in whatever the process dumps.
24
+ *
25
+ * @module dsh-realtime-audio-ws/diagnostics
26
+ */
27
+
28
+ import type { IncomingMessage, ServerResponse } from 'node:http'
29
+ import type { Journal } from 'dsh-realtime'
30
+ import { tokenFromUrl, verdictFor } from './injection.ts'
31
+
32
+ /**
33
+ * The slice of the journal this route reads.
34
+ *
35
+ * Typed as the public surface rather than as the class: `Journal` exists as two declarations in this
36
+ * repository — built `lib/` and `src/` — and a class with a private member is nominally typed, so the
37
+ * two are not assignable to each other even though they are the same code.
38
+ */
39
+ export type DiagnosticsJournal = Pick<Journal, 'snapshot' | 'size' | 'oldestSeq'>
40
+
41
+ /** What the route needs from the plugin that owns it. */
42
+ export interface DiagnosticsDeps {
43
+ /** Absolute pathname to claim, no trailing slash — the registry's own contract. */
44
+ readonly path: string
45
+ /** This process's capability token, the same one the audio route publishes to the page. */
46
+ readonly token: string
47
+ /** The connection service's verdict for one request. */
48
+ readonly rejectionFor: (request: { headers: IncomingMessage['headers'] }) => 401 | 403 | undefined
49
+ /** The journal to serve. */
50
+ readonly journal: DiagnosticsJournal
51
+ }
52
+
53
+ /** A route the host's registry accepts, described structurally as this package describes the registry. */
54
+ export interface DiagnosticsRoute {
55
+ readonly kind: 'exact'
56
+ readonly path: string
57
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => void
58
+ }
59
+
60
+ /**
61
+ * Build the diagnostics route.
62
+ * @param deps - the path to claim, this process's token, the connection service's verdict, and the journal.
63
+ * @returns the route registration, ready for `webServer.register`.
64
+ */
65
+ export function diagnosticsRoute(deps: DiagnosticsDeps): DiagnosticsRoute {
66
+ return {
67
+ kind: 'exact',
68
+ path: deps.path,
69
+ handler: (req, res) => {
70
+ const rejection = verdictFor(deps.rejectionFor(req), tokenFromUrl(req.url), deps.token)
71
+ if (rejection !== undefined) {
72
+ // The status carries the verdict the connection service actually gave, so a reader can tell a
73
+ // missing credential from a refused one rather than guessing from a generic failure.
74
+ send(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
75
+ return
76
+ }
77
+ const entries = deps.journal.snapshot()
78
+ // `size` and `oldestSeq` travel with the entries rather than being left for the reader to infer:
79
+ // a truncated buffer and an idle one are the same empty-looking list otherwise, and the sequence
80
+ // 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 })
82
+ },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Write one JSON response.
88
+ *
89
+ * `no-store` on both branches. This response is a picture of a moving buffer, and a cached one would be
90
+ * read as current — the same class of mistake as a journal that cannot show its own eviction.
91
+ * @param res - the response this handler owns.
92
+ * @param status - HTTP status to write.
93
+ * @param body - value to serialise as the JSON body.
94
+ */
95
+ function send(res: ServerResponse, status: number, body: unknown): void {
96
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
97
+ res.end(JSON.stringify(body))
98
+ }
package/src/index.ts CHANGED
@@ -9,15 +9,20 @@
9
9
  * connection services exist, so a composition without a web stack gets a row that visibly waits rather
10
10
  * than one that loads and silently claims nothing. `packages/realtime-audio-ws/tests/plugin.spec.ts` proves
11
11
  * the functional path against a real socket with both services present.
12
+ *
13
+ * It claims **two** routes on that registry, and one policy authenticates both: the WebSocket upgrade
14
+ * that carries audio, and the JSON diagnostics route that serves the journal. See `./diagnostics.ts` for
15
+ * why the second reuses the first's check rather than deriving its own.
12
16
  */
13
17
 
14
18
  import Schema from '@deepseek-ai/schemastery'
15
19
  import type { Context } from '@deepseek-ai/cordis'
16
- import type { IncomingMessage } from 'node:http'
20
+ import type { IncomingMessage, ServerResponse } from 'node:http'
17
21
  import type { Duplex } from 'node:stream'
18
22
  // Type-only: pulls the `Events` augmentation that declares the two audio events this package bridges.
19
23
  import type {} from 'dsh-realtime-agent'
20
24
  import { attachAudioSocket } from './bridge.ts'
25
+ import { diagnosticsRoute } from './diagnostics.ts'
21
26
  import {
22
27
  createRouteToken,
23
28
  routeAuthority,
@@ -28,6 +33,7 @@ import {
28
33
  } from './injection.ts'
29
34
  import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.ts'
30
35
  import {
36
+ DEFAULT_DIAGNOSTICS_PATH,
31
37
  DEFAULT_MAX_CONNECTIONS,
32
38
  DEFAULT_MAX_FRAME_BYTES,
33
39
  DEFAULT_OPEN_SESSION_ON_CONNECT,
@@ -37,8 +43,17 @@ import {
37
43
  } from './types.ts'
38
44
 
39
45
  export * from './types.ts'
46
+ /**
47
+ * The query parameter carrying the capability token.
48
+ *
49
+ * Published with the route rather than kept internal: nothing can address the route without it, and a
50
+ * consumer restating the literal is a consumer that drifts. It is one string; the rest of `injection.ts`
51
+ * stays where it is.
52
+ */
53
+ export { TOKEN_PARAM } from './injection.ts'
40
54
  export { attachAudioSocket, toBytes, type AudioSocketBridgeDeps } from './bridge.ts'
41
55
  export { createUpgradeAcceptor, rejectUpgrade, type UpgradeAcceptor } from './upgrade.ts'
56
+ export { diagnosticsRoute, type DiagnosticsDeps, type DiagnosticsJournal, type DiagnosticsRoute } from './diagnostics.ts'
42
57
 
43
58
  /** Plugin name. */
44
59
  export const name = 'realtime-audio-ws'
@@ -51,6 +66,7 @@ export const Config = Schema.object({
51
66
  // boot with nobody asking, which is why it is false. This one opens a session because someone connected a
52
67
  // microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
53
68
  openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
69
+ diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
54
70
  })
55
71
 
56
72
  /**
@@ -59,12 +75,21 @@ export const Config = Schema.object({
59
75
  * Structural rather than imported: `@deepseek-ai/dsh-host-webserver` is a DeepSeek package, and importing
60
76
  * it would both add a dependency edge for one shape and augment `Context` with another declaration of
61
77
  * `webServer` — two declarations of one property with different types is a compile error.
78
+ *
79
+ * `register` applies no authentication of its own and knows no harness concepts: the handler owns the
80
+ * whole response. That is why both routes here carry the same explicit check rather than trusting the
81
+ * registry to have made the decision.
62
82
  */
63
83
  interface WebServerLike {
64
84
  registerUpgrade(route: {
65
85
  path: string
66
86
  handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
67
87
  }): () => void
88
+ register(route: {
89
+ kind: 'exact' | 'prefix'
90
+ path: string
91
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
92
+ }): () => void
68
93
  /**
69
94
  * The port actually listening — the OS-assigned value when the config asked for zero. Read at injection
70
95
  * time rather than at boot, because an index render happens after the listen.
@@ -80,11 +105,17 @@ interface ConnectionLike {
80
105
  }
81
106
 
82
107
  export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
83
- ctx.inject(['connection', 'webServer'], (injected) => {
108
+ ctx.inject(['connection', 'webServer', 'realtime'], (injected) => {
84
109
  const services = injected as unknown as { webServer: WebServerLike; connection: ConnectionLike }
85
110
  const acceptor = createUpgradeAcceptor(config.maxFrameBytes)
86
111
  const clients = new Set<AudioSocket>()
87
112
  const token = createRouteToken()
113
+ const journal = injected.realtime.journal
114
+ // The token travels in a URL query, and this route records request targets, so the journal has to be
115
+ // able to redact it before it records one. This is exactly why `addSecrets` is additive instead of a
116
+ // constructor argument: the plugin that mints the token is the only one that can name it, and it
117
+ // mints it here, after the journal already exists.
118
+ journal.addSecrets([token])
88
119
  // The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
89
120
  // does not import: importing it would augment `Context` with a second declaration of `webServer` and
90
121
  // turn the structural dependency above into a compile error. The name is cast at this one boundary.
@@ -104,6 +135,12 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
104
135
  token,
105
136
  ))
106
137
  })
138
+ const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
139
+ path: config.diagnosticsPath,
140
+ token,
141
+ rejectionFor: (request) => services.connection.requestRejection(request),
142
+ journal,
143
+ }))
107
144
  const unregister = services.webServer.registerUpgrade({
108
145
  path: config.path,
109
146
  handler: (req, socket, head) => {
@@ -117,22 +154,38 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
117
154
  // from `dsh-app://app`, so its requests to loopback are cross-site and the harness's
118
155
  // `SameSite=Strict` cookie cannot travel; without the token that page is refused forever, however
119
156
  // correct the rest of the client half is.
157
+ // Normalised once, at the caller's edge, so no record below repeats the check. `node:http`
158
+ // always reports a target on a server request; the type admits `undefined` for client requests,
159
+ // which this handler cannot see.
160
+ const target = req.url ?? ''
120
161
  const rejection = verdictFor(
121
162
  services.connection.requestRejection(req),
122
163
  tokenFromUrl(req.url),
123
164
  token,
124
165
  )
125
166
  if (rejection !== undefined) {
167
+ // The verdict only, and no target. A valid token is never rejected — it overrides the
168
+ // refusal — so any target recorded here belongs to something else, and a wrong token is
169
+ // still a credential. Retaining a caller's secret in order to log a refusal is a worse trade
170
+ // than losing which path was probed.
171
+ journal.record('socket.rejected', { verdict: String(rejection) })
126
172
  rejectUpgrade(socket, rejection)
127
173
  return
128
174
  }
129
175
  acceptor.handleUpgrade(req, socket, head, (client) => {
130
176
  if (clients.size >= config.maxConnections) {
131
177
  // 1013 = try again later. Closing the newcomer leaves the existing microphone live.
178
+ journal.record('socket.rejected', { verdict: '1013', reason: 'busy' })
132
179
  client.close(1013, 'busy')
133
180
  return
134
181
  }
135
182
  clients.add(client)
183
+ // The target here, because an accepted request is the one place this process's own token
184
+ // travels: the app page presents it in the query precisely because it cannot carry the
185
+ // cookie. The journal redacts it on write — which is what the token was added to the journal's
186
+ // secrets for, and the reason that has to be additive rather than a constructor argument,
187
+ // since the token does not exist until this plugin applies.
188
+ journal.record('socket.accepted', { clients: String(clients.size), url: target })
136
189
  // Ask for the session before the bridge goes in, so the first frames are written into a session
137
190
  // that is being opened rather than dropped by the mic seam's no-session rule.
138
191
  if (config.openSessionOnConnect) ctx.emit('realtime-agent/start')
@@ -142,6 +195,7 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
142
195
  maxFrameBytes: config.maxFrameBytes,
143
196
  onDetach: () => {
144
197
  clients.delete(client)
198
+ journal.record('socket.closed', { clients: String(clients.size) })
145
199
  // The last client leaving ends the session. This also covers the transport's own disposal,
146
200
  // which terminates its clients — so a profile reload does not leave a session nobody holds.
147
201
  if (config.openSessionOnConnect && clients.size === 0) ctx.emit('realtime-agent/stop')
@@ -152,6 +206,7 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
152
206
  })
153
207
  return async () => {
154
208
  unregister()
209
+ unregisterDiagnostics()
155
210
  for (const client of clients) client.terminate()
156
211
  clients.clear()
157
212
  await acceptor.close()
package/src/types.ts CHANGED
@@ -24,6 +24,15 @@ export const DEFAULT_MAX_CONNECTIONS = 1
24
24
  /** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
25
25
  export const DEFAULT_OPEN_SESSION_ON_CONNECT = true
26
26
 
27
+ /**
28
+ * Default pathname the diagnostics route claims.
29
+ *
30
+ * A fixed API path in spirit, but a validated config field like the audio route's — so a composition
31
+ * that already owns this path can move it deliberately rather than discovering the collision when the
32
+ * registry throws at registration.
33
+ */
34
+ export const DEFAULT_DIAGNOSTICS_PATH = '/dsh-realtime/diagnostics'
35
+
27
36
  export interface RealtimeAudioWsConfig {
28
37
  /**
29
38
  * Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
@@ -50,6 +59,14 @@ export interface RealtimeAudioWsConfig {
50
59
  * that looks like a fault anywhere but in this file.
51
60
  */
52
61
  readonly openSessionOnConnect: boolean
62
+ /**
63
+ * Absolute pathname the diagnostics route claims, served as JSON.
64
+ *
65
+ * Authenticated by the same policy as the audio route, and for the same reason: it is an HTTP route on
66
+ * loopback that the host's registry does not gate for us, and it reports the journal — which is worth
67
+ * more to a stranger than an empty socket.
68
+ */
69
+ readonly diagnosticsPath: string
53
70
  }
54
71
 
55
72
  /**