@ggui-ai/protocol-reference-server 0.2.0-alpha.4 → 0.4.0-rc.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.
package/dist/server.js CHANGED
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * `ReferenceServer` — the minimal WS live-channel server this package
3
- * exports. Honest scope: SPEC §12.2 wire, version handshake,
4
- * wired-action dispatch. That's it.
3
+ * exports. Honest scope: SPEC §12.2 wire (subscribe incl. the §12.2
4
+ * appId-tenancy MUST → APP_MISMATCH), version handshake, the single
5
+ * action-routing model (action → consume-buffer append → ack with
6
+ * sequence; undeclared actions AND schema-violating payloads rejected
7
+ * with CONTRACT_VIOLATION per SPEC §4.6 receipt validation), and
8
+ * `host_context_observed` persistence onto `GguiSession.hostContext`.
9
+ * That's it.
5
10
  *
6
11
  * No auth (accepts any bearer). No persistence. No bundle loading.
7
12
  * The whole point is to be narrow enough that the vendor-neutral
@@ -9,22 +14,19 @@
9
14
  * server passes `@ggui-ai/protocol-conformance`, the protocol has
10
15
  * no implicit `@ggui-ai/mcp-server` coupling.
11
16
  *
12
- * Wire-field note: the consumer (`@ggui-ai/protocol-conformance`)
13
- * still names the render identity field `sessionId` on the wire (its
14
- * fixtures have not yet been renamed). The reference server honors
15
- * the consumer contract by reading that field name verbatim, then
16
- * binds the value to a `renderId` internally — see {@link Render}
17
- * for the canonical identity name used throughout this package.
17
+ * Wire-field note: the GguiSession identity field is the canonical SPEC
18
+ * field `sessionId` — see {@link GguiSession} for the identity name used
19
+ * throughout this package.
18
20
  */
19
21
  import { createServer } from 'node:http';
20
22
  import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
21
23
  import { WebSocketServer } from 'ws';
22
- import { dispatchAction, parseActionFrame } from './action-router.js';
23
- import { RenderStore } from './render.js';
24
- import { ToolRegistry } from './tool-registry.js';
24
+ import { handleAction, parseActionFrame } from './action-router.js';
25
+ import { handleHostContextObserved, parseHostContextObservedFrame, } from './host-context.js';
26
+ import { isRecord } from '@ggui-ai/protocol';
27
+ import { DEPLOYMENT_DEFAULT_APP_ID, GguiSessionStore, } from './render.js';
25
28
  export class ReferenceServer {
26
- renders = new RenderStore();
27
- tools = new ToolRegistry();
29
+ renders = new GguiSessionStore();
28
30
  options;
29
31
  http = null;
30
32
  wss = null;
@@ -104,7 +106,7 @@ export class ReferenceServer {
104
106
  handleConnection(socket) {
105
107
  // Subscribe state is per-connection — one WS may subscribe to
106
108
  // one render at a time. Re-subscribe overwrites.
107
- let subscribedRenderId = null;
109
+ let subscribedSessionId = null;
108
110
  const subscriber = {
109
111
  send: (frame) => {
110
112
  try {
@@ -116,26 +118,26 @@ export class ReferenceServer {
116
118
  },
117
119
  };
118
120
  socket.on('message', (raw) => {
119
- void this.handleMessage(raw.toString('utf8'), {
121
+ this.handleMessage(raw.toString('utf8'), {
120
122
  socket,
121
123
  subscriber,
122
- onSubscribed: (renderId) => {
124
+ onSubscribed: (sessionId) => {
123
125
  // If previously subscribed to a different render, unsub
124
126
  // from it first.
125
- if (subscribedRenderId !== null && subscribedRenderId !== renderId) {
126
- this.renders.removeSubscriber(subscribedRenderId, subscriber);
127
+ if (subscribedSessionId !== null && subscribedSessionId !== sessionId) {
128
+ this.renders.removeSubscriber(subscribedSessionId, subscriber);
127
129
  }
128
- subscribedRenderId = renderId;
130
+ subscribedSessionId = sessionId;
129
131
  },
130
132
  });
131
133
  });
132
134
  socket.on('close', () => {
133
- if (subscribedRenderId !== null) {
134
- this.renders.removeSubscriber(subscribedRenderId, subscriber);
135
+ if (subscribedSessionId !== null) {
136
+ this.renders.removeSubscriber(subscribedSessionId, subscriber);
135
137
  }
136
138
  });
137
139
  }
138
- async handleMessage(text, ctx) {
140
+ handleMessage(text, ctx) {
139
141
  let frame;
140
142
  try {
141
143
  frame = JSON.parse(text);
@@ -146,25 +148,43 @@ export class ReferenceServer {
146
148
  // server keeps it silent so `no-op` fixtures pass.
147
149
  return;
148
150
  }
149
- if (frame === null || typeof frame !== 'object')
151
+ if (!isRecord(frame))
150
152
  return;
151
- const f = frame;
152
- if (f['type'] === 'subscribe') {
153
- this.handleSubscribe(f, {
153
+ if (frame['type'] === 'subscribe') {
154
+ this.handleSubscribe(frame, {
154
155
  subscriber: ctx.subscriber,
155
156
  onSubscribed: ctx.onSubscribed,
156
157
  socket: ctx.socket,
157
158
  });
158
159
  return;
159
160
  }
160
- if (f['type'] === 'action') {
161
+ if (frame['type'] === 'action') {
161
162
  const parsed = parseActionFrame(frame);
162
163
  if (parsed === undefined)
163
164
  return; // malformed — silently drop
164
- const render = this.renders.get(parsed.renderId);
165
+ const render = this.renders.get(parsed.payload.sessionId);
165
166
  if (render === undefined)
166
167
  return; // drop actions for unknown renders
167
- await dispatchAction(parsed, { render, tools: this.tools });
168
+ // Ack + contract rejections reply to the SENDING socket — the
169
+ // dispatcher gets the persistence proof, not the broadcast set.
170
+ handleAction(parsed, { render, reply: ctx.subscriber });
171
+ return;
172
+ }
173
+ if (frame['type'] === 'host_context_observed') {
174
+ // Fire-and-forget observation message — no response frame. The
175
+ // obligation is purely stateful: persist the validated
176
+ // projection onto the named render (idempotent overwrite). The
177
+ // first-party server scopes the write through its subscriber
178
+ // binding; this no-auth server's tenancy scope is the render
179
+ // lookup itself — malformed frames and unknown renders drop,
180
+ // mirroring the action path.
181
+ const parsed = parseHostContextObservedFrame(frame);
182
+ if (parsed === undefined)
183
+ return; // malformed — silently drop
184
+ const render = this.renders.get(parsed.payload.sessionId);
185
+ if (render === undefined)
186
+ return; // drop observations for unknown renders
187
+ handleHostContextObserved(parsed, render);
168
188
  return;
169
189
  }
170
190
  // Unrecognized type — silently drop (extensibly-closed; third
@@ -172,24 +192,31 @@ export class ReferenceServer {
172
192
  }
173
193
  handleSubscribe(frame, ctx) {
174
194
  const payload = frame['payload'];
175
- if (payload === null || typeof payload !== 'object')
195
+ if (!isRecord(payload))
196
+ return;
197
+ // GguiSession-identity field: the canonical SPEC field `sessionId`.
198
+ const sessionId = typeof payload['sessionId'] === 'string' ? payload['sessionId'] : undefined;
199
+ if (sessionId === undefined)
176
200
  return;
177
- const p = payload;
178
- // Wire-field acceptance: the conformance kit currently sends
179
- // `sessionId`; the canonical SPEC field is `renderId`. Read both
180
- // so the reference server is forward-compatible with the kit's
181
- // eventual rename without breaking today's fixtures.
182
- const renderId = typeof p['renderId'] === 'string'
183
- ? p['renderId']
184
- : typeof p['sessionId'] === 'string'
185
- ? p['sessionId']
186
- : undefined;
187
- if (renderId === undefined)
201
+ // `appId` is OPTIONAL on the subscribe payload (SPEC §12.2 field
202
+ // table): absent ⇒ the server resolves the caller's
203
+ // identity-default app. This server's identity model is no-auth —
204
+ // every caller is the same anonymous identity — so the
205
+ // identity-default collapses to the deployment-level
206
+ // {@link DEPLOYMENT_DEFAULT_APP_ID}; the resolved value flows
207
+ // through the SAME tenancy gate + provision-on-subscribe path a
208
+ // client-supplied appId takes. A PRESENT-but-non-string `appId` is
209
+ // still a malformed frame and takes the missing-`sessionId`
210
+ // posture: silently dropped, no error frame, no render
211
+ // provisioned.
212
+ const rawAppId = payload['appId'];
213
+ if (rawAppId !== undefined && typeof rawAppId !== 'string')
188
214
  return;
189
- const appId = typeof p['appId'] === 'string' ? p['appId'] : 'conformance';
215
+ const appId = rawAppId ?? DEPLOYMENT_DEFAULT_APP_ID;
190
216
  const requestId = typeof frame['requestId'] === 'string' ? frame['requestId'] : undefined;
191
- const supportedVersions = Array.isArray(p['supportedVersions'])
192
- ? p['supportedVersions'].filter((v) => typeof v === 'string')
217
+ const rawVersions = payload['supportedVersions'];
218
+ const supportedVersions = Array.isArray(rawVersions)
219
+ ? rawVersions.filter((v) => typeof v === 'string')
193
220
  : undefined;
194
221
  // Version handshake — if the client declared `supportedVersions`
195
222
  // AND our current schema-version is not in the list, emit
@@ -205,7 +232,7 @@ export class ReferenceServer {
205
232
  // subscribe landed, advertise that value instead of the instance-
206
233
  // level default. Lets parallel kit fixtures share one server while
207
234
  // mismatching version on exactly one render.
208
- const existingRender = this.renders.get(renderId);
235
+ const existingRender = this.renders.get(sessionId);
209
236
  const advertised = existingRender?.versionOverride ?? this.options.versionOverride;
210
237
  if (supportedVersions !== undefined && !supportedVersions.includes(advertised)) {
211
238
  ctx.subscriber.send({
@@ -227,12 +254,39 @@ export class ReferenceServer {
227
254
  }
228
255
  return;
229
256
  }
230
- // Add the subscriber + emit ack.
231
- this.renders.addSubscriber(renderId, ctx.subscriber);
232
- // Preserve appId on first subscribe — create() is no-op if the
233
- // render already exists from an earlier directive.
234
- this.renders.create(renderId, appId);
235
- ctx.onSubscribed(renderId);
257
+ // SPEC §12.2 tenancy MUST: the subscribe's `appId` MUST match the
258
+ // GguiSession's bound appId or the subscribe fails APP_MISMATCH
259
+ // (§12.2.3). The code is deliberately distinct from
260
+ // SESSION_NOT_FOUND — the GguiSession EXISTS, it is reachable only
261
+ // from a different app, so the client's recovery is "fix your
262
+ // appId / API key", not "re-handshake". Only an existing render
263
+ // can mismatch: an unknown sessionId falls through to the
264
+ // provision-on-subscribe path below, which binds the subscribe's
265
+ // own appId (mirroring the first-party dev-mode posture: look up
266
+ // first; if not present, create). The mismatch rejects without
267
+ // registering a subscriber and without an ack; the socket stays
268
+ // open (matching the first-party handler).
269
+ if (existingRender !== undefined && existingRender.appId !== appId) {
270
+ ctx.subscriber.send({
271
+ type: 'error',
272
+ payload: {
273
+ code: 'APP_MISMATCH',
274
+ message: `GguiSession '${sessionId}' belongs to a different app`,
275
+ },
276
+ ...(requestId !== undefined ? { requestId } : {}),
277
+ });
278
+ return;
279
+ }
280
+ // Bind the render BEFORE registering the subscriber: create() is a
281
+ // no-op when the render already exists (preserving the appId an
282
+ // earlier directive bound), and for provision-on-subscribe it
283
+ // binds the subscribe payload's own appId. Ordering matters —
284
+ // `addSubscriber`'s create-if-missing fallback binds the default
285
+ // app, which would make this render reject the SAME client's next
286
+ // subscribe with APP_MISMATCH.
287
+ this.renders.create(sessionId, appId);
288
+ this.renders.addSubscriber(sessionId, ctx.subscriber);
289
+ ctx.onSubscribed(sessionId);
236
290
  ctx.subscriber.send({
237
291
  type: 'ack',
238
292
  payload: { serverVersion: advertised },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/protocol-reference-server",
3
- "version": "0.2.0-alpha.4",
3
+ "version": "0.4.0-rc.0",
4
4
  "description": "Minimal reference implementation of the ggui protocol. Implements exactly enough of the live-channel WebSocket wire to pass the @ggui-ai/protocol-conformance kit. Not a production server — it exists to prove the protocol is vendor-neutral: an independent, from-scratch implementation passing the kit grounds that claim empirically.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -29,14 +29,14 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "ws": "^8.20.1",
32
- "@ggui-ai/protocol": "0.2.0-alpha.4"
32
+ "@ggui-ai/protocol": "0.4.0-rc.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.0.0",
36
36
  "@types/ws": "^8.5.10",
37
37
  "typescript": "^5.0.0",
38
- "vitest": "^3.0.0",
39
- "@ggui-ai/protocol-conformance": "0.2.0-alpha.4"
38
+ "vitest": "^3.2.6",
39
+ "@ggui-ai/protocol-conformance": "0.4.0-rc.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "author": "ggui contributors <hello@ggui.ai>",
57
57
  "scripts": {
58
- "build": "tsc -p tsconfig.build.json && node ../scripts/fix-esm-imports.mjs dist && chmod +x dist/cli.js",
58
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../scripts/fix-esm-imports.mjs dist && chmod +x dist/cli.js",
59
59
  "dev": "tsc --watch",
60
60
  "typecheck": "tsc --noEmit",
61
61
  "test": "vitest run --passWithNoTests",
@@ -1,63 +0,0 @@
1
- /**
2
- * Tool registry — the 4 handler kinds the reference server wires
3
- * through wired-action dispatch. Names match the `handler` enumeration
4
- * in `packages/protocol-conformance/src/conformance-host.ts`'s
5
- * `RegisterToolSetup` so the conformance kit's `register-tool`
6
- * directive drops through cleanly.
7
- *
8
- * The four handler kinds:
9
- *
10
- * - `echo` — returns `{received: args}`.
11
- * - `throw` — rejects with `Error('tool_threw_for_fixture')`.
12
- * - `timeout` — never resolves; router enforces a 500ms timeout.
13
- * - `malformed`— returns `{wrong: 'shape'}` to exercise
14
- * SCHEMA_VIOLATION.
15
- *
16
- * `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
17
- * to an action whose tool is NOT in the registry; no handler needed.
18
- *
19
- * Handlers are declarative: they return `{status: 'resolved', value}`
20
- * or throw. The router consults the return shape against the
21
- * declared channel schema (minimal — currently only `malformed` is
22
- * flagged) and maps unsupported cases to `_ggui:contract-error` with
23
- * the matching ContractErrorCode.
24
- */
25
- /**
26
- * One tool's executable behavior. Async so `timeout` can return a
27
- * never-resolving promise the router bounds with a timer.
28
- */
29
- export type ToolHandler = (args: unknown) => Promise<unknown>;
30
- export type ToolHandlerKind = 'echo' | 'throw' | 'timeout' | 'malformed' | 'malformed-stream' | 'list-snapshot' | (string & {});
31
- /**
32
- * Registered tool — the handler + its declared behavior kind so
33
- * the router can match against fixture expectations.
34
- */
35
- export interface RegisteredTool {
36
- readonly name: string;
37
- readonly kind: ToolHandlerKind;
38
- readonly handler: ToolHandler;
39
- }
40
- /**
41
- * Build one of the four canonical handlers by kind. Unknown kinds
42
- * throw — the caller (ConformanceHost adapter or setup-step
43
- * dispatcher) MUST surface this as an honest "handler not implemented"
44
- * error so the kit records a SKIP with the error message as reason.
45
- */
46
- export declare function buildHandler(kind: ToolHandlerKind): ToolHandler;
47
- /**
48
- * In-memory tool registry. Scoped to a render via the action router
49
- * (the plan's register-tool directive wires a handler under the
50
- * render's tool namespace). No-persistence by design.
51
- */
52
- export declare class ToolRegistry {
53
- private readonly tools;
54
- register(name: string, kind: ToolHandlerKind): void;
55
- unregister(name: string): boolean;
56
- get(name: string): RegisteredTool | undefined;
57
- has(name: string): boolean;
58
- /** Name of the first tool registered on this registry, or
59
- * `undefined` if none. Used by the action-router as a fallback
60
- * when no explicit action→tool binding exists. */
61
- firstRegistered(): string | undefined;
62
- }
63
- //# sourceMappingURL=tool-registry.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE9D,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,OAAO,GACP,SAAS,GACT,WAAW,GACX,kBAAkB,GAClB,eAAe,GACf,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAYD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAkC/D;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;IAInD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAI7C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B;;uDAEmD;IACnD,eAAe,IAAI,MAAM,GAAG,SAAS;CAItC"}
@@ -1,99 +0,0 @@
1
- /**
2
- * Tool registry — the 4 handler kinds the reference server wires
3
- * through wired-action dispatch. Names match the `handler` enumeration
4
- * in `packages/protocol-conformance/src/conformance-host.ts`'s
5
- * `RegisterToolSetup` so the conformance kit's `register-tool`
6
- * directive drops through cleanly.
7
- *
8
- * The four handler kinds:
9
- *
10
- * - `echo` — returns `{received: args}`.
11
- * - `throw` — rejects with `Error('tool_threw_for_fixture')`.
12
- * - `timeout` — never resolves; router enforces a 500ms timeout.
13
- * - `malformed`— returns `{wrong: 'shape'}` to exercise
14
- * SCHEMA_VIOLATION.
15
- *
16
- * `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
17
- * to an action whose tool is NOT in the registry; no handler needed.
18
- *
19
- * Handlers are declarative: they return `{status: 'resolved', value}`
20
- * or throw. The router consults the return shape against the
21
- * declared channel schema (minimal — currently only `malformed` is
22
- * flagged) and maps unsupported cases to `_ggui:contract-error` with
23
- * the matching ContractErrorCode.
24
- */
25
- /**
26
- * Returns a never-resolving promise. The router pairs it with a
27
- * timer to emit `TOOL_TIMEOUT` contract-error after N ms.
28
- */
29
- function neverResolve() {
30
- return new Promise(() => {
31
- /* deliberate: never settles */
32
- });
33
- }
34
- /**
35
- * Build one of the four canonical handlers by kind. Unknown kinds
36
- * throw — the caller (ConformanceHost adapter or setup-step
37
- * dispatcher) MUST surface this as an honest "handler not implemented"
38
- * error so the kit records a SKIP with the error message as reason.
39
- */
40
- export function buildHandler(kind) {
41
- switch (kind) {
42
- case 'echo':
43
- return async (args) => ({ received: args });
44
- case 'throw':
45
- return async () => {
46
- throw new Error('tool_threw_for_fixture');
47
- };
48
- case 'timeout':
49
- return () => neverResolve();
50
- case 'malformed':
51
- case 'malformed-stream':
52
- // Both kinds return a shape that does not match the declared
53
- // channel schema — router maps to SCHEMA_VIOLATION.
54
- // `malformed-stream` is the fixture-authored alias used by
55
- // `stream-schema-violation` in the kit.
56
- return async () => ({ wrong: 'shape' });
57
- case 'list-snapshot':
58
- // Refresh-tool kind: returns a deterministic empty list snapshot
59
- // (`{ items: [] }`). Models the "list-fresh-state" role of a
60
- // streamSpec refresh tool (real ggui blueprints' `tasks_list` /
61
- // `notes_list` etc.). The reference server runs handlers
62
- // statelessly — the snapshot intentionally does NOT reflect
63
- // prior `tasks_create` calls, since modeling stateful in-memory
64
- // stores is out of scope for the smallest conformant impl. The
65
- // kit's `stream-refresh-success` matcher only asserts the
66
- // channel-update arrived with the declared shape, not that the
67
- // payload reflects mutation history.
68
- return async () => ({ items: [] });
69
- default:
70
- throw new Error(`reference-server: tool handler kind '${String(kind)}' is not recognized — supported: echo, throw, timeout, malformed, malformed-stream, list-snapshot`);
71
- }
72
- }
73
- /**
74
- * In-memory tool registry. Scoped to a render via the action router
75
- * (the plan's register-tool directive wires a handler under the
76
- * render's tool namespace). No-persistence by design.
77
- */
78
- export class ToolRegistry {
79
- tools = new Map();
80
- register(name, kind) {
81
- this.tools.set(name, { name, kind, handler: buildHandler(kind) });
82
- }
83
- unregister(name) {
84
- return this.tools.delete(name);
85
- }
86
- get(name) {
87
- return this.tools.get(name);
88
- }
89
- has(name) {
90
- return this.tools.has(name);
91
- }
92
- /** Name of the first tool registered on this registry, or
93
- * `undefined` if none. Used by the action-router as a fallback
94
- * when no explicit action→tool binding exists. */
95
- firstRegistered() {
96
- const it = this.tools.keys().next();
97
- return it.done === true ? undefined : it.value;
98
- }
99
- }