@happyvertical/smrt-svelte 0.42.6 → 0.43.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.
Files changed (54) hide show
  1. package/README.md +20 -0
  2. package/dist/Provider.svelte +49 -0
  3. package/dist/Provider.svelte.d.ts +15 -0
  4. package/dist/Provider.svelte.d.ts.map +1 -1
  5. package/dist/__tests__/data-surface-bridge.test.js +351 -0
  6. package/dist/components/forms/AddressInput.svelte +11 -0
  7. package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
  8. package/dist/components/forms/CheckboxInput.svelte +3 -1
  9. package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
  10. package/dist/components/forms/DateRangeInput.svelte +3 -0
  11. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  12. package/dist/components/forms/DateTimeInput.svelte +2 -0
  13. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  14. package/dist/components/forms/Form.svelte +172 -6
  15. package/dist/components/forms/Form.svelte.d.ts +8 -1
  16. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  17. package/dist/components/forms/MeasurementInput.svelte +3 -0
  18. package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
  19. package/dist/components/forms/MoneyInput.svelte +3 -0
  20. package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/NumberInput.svelte +3 -0
  22. package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/PhoneInput.svelte +2 -0
  24. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  25. package/dist/components/forms/SelectInput.svelte +7 -1
  26. package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
  27. package/dist/components/forms/TextInput.svelte +5 -1
  28. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  29. package/dist/components/forms/TextareaInput.svelte +3 -1
  30. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/__tests__/Form.webmcp.test.js +216 -0
  32. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +25 -3
  33. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +5 -0
  34. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
  35. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +40 -0
  36. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +10 -0
  37. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -0
  38. package/dist/data-surface.d.ts +104 -0
  39. package/dist/data-surface.d.ts.map +1 -0
  40. package/dist/data-surface.js +348 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -0
  44. package/dist/state/form-context.d.ts +2 -0
  45. package/dist/state/form-context.d.ts.map +1 -1
  46. package/dist/web/__tests__/webmcp-harness.svelte +14 -0
  47. package/dist/web/__tests__/webmcp-harness.svelte.d.ts +7 -0
  48. package/dist/web/__tests__/webmcp-harness.svelte.d.ts.map +1 -0
  49. package/dist/web/__tests__/webmcp.test.js +39 -0
  50. package/dist/web/webmcp.d.ts +21 -0
  51. package/dist/web/webmcp.svelte.d.ts +28 -0
  52. package/dist/web/webmcp.svelte.d.ts.map +1 -0
  53. package/dist/web/webmcp.svelte.js +27 -0
  54. package/package.json +17 -5
package/README.md CHANGED
@@ -38,6 +38,26 @@ Svelte 5 component library for the s-m-r-t framework. Provides UI components, br
38
38
  pnpm add @happyvertical/smrt-svelte
39
39
  ```
40
40
 
41
+ ## Data-surface browser bridge security
42
+
43
+ The browser bridge is a transport adapter, not an authentication system.
44
+ Configure it only with a session/source binding established by the server and
45
+ use a transport that supplies verified peer metadata. It accepts commands only
46
+ from the configured server peer and emits acknowledgements/events only on the
47
+ bound route; wire `sessionId` and `source` fields must never be treated as
48
+ proof of identity.
49
+
50
+ The adapter canonicalizes requests and applies the shared identifier limit
51
+ from `@happyvertical/smrt-ui/data-surface` along with bounded envelopes before
52
+ calling the registry. The registry remains the authority for command
53
+ authorization and execution. Command IDs are idempotent while their bounded
54
+ replay entries are retained; concurrent same-signature requests coalesce, a
55
+ conflicting signature is rejected, and replay-capacity exhaustion is reported
56
+ explicitly. Malformed requests and unauthenticated peers are ignored before an
57
+ acknowledgement; valid requests that expire or encounter disconnect and
58
+ transport failures produce bounded protocol outcomes without exposing registry
59
+ state.
60
+
41
61
  ## Usage
42
62
 
43
63
  ### Provider Setup
@@ -4,6 +4,11 @@ import {
4
4
  type I18nSnapshot,
5
5
  setI18nContext,
6
6
  } from '@happyvertical/smrt-ui/i18n';
7
+ import {
8
+ type RegisterWebMcpToolsOptions,
9
+ type SmrtWebClient,
10
+ type SmrtWebCollectionDefinition,
11
+ } from '@happyvertical/smrt-web';
7
12
  import type { Snippet } from 'svelte';
8
13
  import { onDestroy, untrack } from 'svelte';
9
14
  import { logger } from './internal/logger.js';
@@ -78,12 +83,27 @@ interface Props {
78
83
  * English-default-only rendering.
79
84
  */
80
85
  i18n?: I18nSnapshot;
86
+ /**
87
+ * Opt in to the generated collection tools for this browser surface.
88
+ * WebMCP is feature-detected by smrt-web, so this is safe during SSR and on
89
+ * browsers that do not expose `document.modelContext`.
90
+ */
91
+ webmcp?: boolean | WebMcpProviderConfig;
81
92
  /**
82
93
  * Children to render
83
94
  */
84
95
  children: Snippet;
85
96
  }
86
97
 
98
+ export interface WebMcpProviderConfig {
99
+ definitions?: SmrtWebCollectionDefinition[];
100
+ client?: SmrtWebClient;
101
+ basePath?: string;
102
+ fetchFn?: typeof fetch;
103
+ scope?: string;
104
+ filter?: RegisterWebMcpToolsOptions['filter'];
105
+ }
106
+
87
107
  const {
88
108
  mode,
89
109
  autoEnableSmrt = true,
@@ -95,6 +115,7 @@ const {
95
115
  onModeChange,
96
116
  onAILoadingChange,
97
117
  i18n,
118
+ webmcp,
98
119
  children,
99
120
  }: Props = $props();
100
121
 
@@ -128,6 +149,34 @@ $effect(() => {
128
149
  });
129
150
  });
130
151
 
152
+ // Keep generated data-plane tools scoped to this Provider. The registrar
153
+ // feature-detects WebMCP itself; effects do not run during SSR.
154
+ $effect(() => {
155
+ if (typeof window === 'undefined' || !webmcp) return;
156
+
157
+ const config = typeof webmcp === 'object' ? webmcp : {};
158
+ let cancelled = false;
159
+ let dispose = () => {};
160
+
161
+ // Keep the data-plane engine out of applications that do not opt in. The
162
+ // dynamic import also means SSR never evaluates browser-only engine code.
163
+ void import('@happyvertical/smrt-web').then(({ registerWebMcpTools }) => {
164
+ if (cancelled) return;
165
+ dispose = registerWebMcpTools(config.definitions ?? [], {
166
+ ...(config.client ? { client: config.client } : {}),
167
+ ...(config.basePath ? { basePath: config.basePath } : {}),
168
+ ...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
169
+ ...(config.scope ? { scope: config.scope } : {}),
170
+ ...(config.filter ? { filter: config.filter } : {}),
171
+ });
172
+ });
173
+
174
+ return () => {
175
+ cancelled = true;
176
+ dispose();
177
+ };
178
+ });
179
+
131
180
  $effect(() => {
132
181
  if (mode) {
133
182
  appState.setMode(mode, 'explicit');
@@ -1,6 +1,15 @@
1
1
  import { type I18nSnapshot } from '@happyvertical/smrt-ui/i18n';
2
+ import { type RegisterWebMcpToolsOptions, type SmrtWebClient, type SmrtWebCollectionDefinition } from '@happyvertical/smrt-web';
2
3
  import type { Snippet } from 'svelte';
3
4
  import type { AIConfig, AILoadingState, AppMode, SocketConfig, User } from './state/app-state.js';
5
+ export interface WebMcpProviderConfig {
6
+ definitions?: SmrtWebCollectionDefinition[];
7
+ client?: SmrtWebClient;
8
+ basePath?: string;
9
+ fetchFn?: typeof fetch;
10
+ scope?: string;
11
+ filter?: RegisterWebMcpToolsOptions['filter'];
12
+ }
4
13
  interface Props {
5
14
  /**
6
15
  * Initial mode: 'default' | 'smrt'
@@ -62,6 +71,12 @@ interface Props {
62
71
  * English-default-only rendering.
63
72
  */
64
73
  i18n?: I18nSnapshot;
74
+ /**
75
+ * Opt in to the generated collection tools for this browser surface.
76
+ * WebMCP is feature-detected by smrt-web, so this is safe during SSR and on
77
+ * browsers that do not expose `document.modelContext`.
78
+ */
79
+ webmcp?: boolean | WebMcpProviderConfig;
65
80
  /**
66
81
  * Children to render
67
82
  */
@@ -1 +1 @@
1
- {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAK9B,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AA6ID,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Provider.svelte.d.ts","sourceRoot":"","sources":["../src/Provider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,YAAY,EAElB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,aAAa,EAClB,KAAK,2BAA2B,EACjC,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,YAAY,EACZ,IAAI,EACL,MAAM,sBAAsB,CAAC;AAK9B,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,2BAA2B,EAAE,CAAC;IAC5C,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,0BAA0B,CAAC,QAAQ,CAAC,CAAC;CAC/C;AAED,UAAU,KAAK;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;;;;;;;;;;OAeG;IACH,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC;;OAEG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,oBAAoB,CAAC;IACxC;;OAEG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AA4KD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,351 @@
1
+ import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { createDataSurfaceBrowserBridge, DATA_SURFACE_IDENTIFIER_MAX_LENGTH, } from '../data-surface.js';
4
+ const identity = { surfaceId: 'orders', kind: 'table' };
5
+ const descriptor = {
6
+ version: 1,
7
+ identity,
8
+ schemaVersion: 1,
9
+ label: 'Orders',
10
+ rowKey: 'id',
11
+ columns: [{ id: 'id', label: 'ID', capabilities: ['read'] }],
12
+ query: { modes: ['rows'], projectableColumnIds: ['id'] },
13
+ controls: [{ id: 'refresh', label: 'Refresh' }],
14
+ actions: [],
15
+ limits: { maxQueryRows: 10, maxQueryBytes: 1000, maxSelectionSize: 10 },
16
+ };
17
+ function request(overrides = {}) {
18
+ return {
19
+ type: 'data-surface.command',
20
+ version: 1,
21
+ commandId: 'command-1',
22
+ sessionId: 'session-1',
23
+ source: 'server-1',
24
+ expiresAt: 10_000,
25
+ identity,
26
+ expectedRevision: 1,
27
+ controlId: 'refresh',
28
+ ...overrides,
29
+ };
30
+ }
31
+ function transport() {
32
+ const messages = [];
33
+ const listeners = new Set();
34
+ return {
35
+ messages,
36
+ send: vi.fn((message) => {
37
+ messages.push(message);
38
+ }),
39
+ subscribe(listener) {
40
+ listeners.add(listener);
41
+ return () => listeners.delete(listener);
42
+ },
43
+ receive(message, peer = {
44
+ sessionId: 'session-1',
45
+ source: 'server-1',
46
+ }) {
47
+ for (const listener of listeners)
48
+ listener(message, peer);
49
+ },
50
+ };
51
+ }
52
+ function fixture() {
53
+ let revision = 1;
54
+ const execute = vi.fn(() => {
55
+ revision += 1;
56
+ });
57
+ const registry = createDataSurfaceRegistry();
58
+ registry.register({
59
+ descriptor,
60
+ getSnapshot: () => ({ revision, state: { refreshed: revision > 1 } }),
61
+ execute,
62
+ });
63
+ return { registry, execute };
64
+ }
65
+ const drain = () => new Promise((resolve) => setTimeout(resolve, 0));
66
+ describe('data-surface browser bridge', () => {
67
+ it('requires the bound session/source and acknowledges a successful command', async () => {
68
+ const { registry, execute } = fixture();
69
+ const link = transport();
70
+ createDataSurfaceBrowserBridge({
71
+ registry,
72
+ transport: link,
73
+ sessionId: 'session-1',
74
+ source: 'browser-1',
75
+ peerSource: 'server-1',
76
+ now: () => 1_000,
77
+ });
78
+ await link.receive(request({ expiresAt: 2_000 }));
79
+ await drain();
80
+ expect(execute).toHaveBeenCalledTimes(1);
81
+ expect(link.messages.at(-1)).toMatchObject({
82
+ type: 'data-surface.ack',
83
+ commandId: 'command-1',
84
+ sessionId: 'session-1',
85
+ source: 'browser-1',
86
+ ok: true,
87
+ });
88
+ await link.receive(request({ expiresAt: 5_000 }));
89
+ await drain();
90
+ expect(execute).toHaveBeenCalledTimes(1);
91
+ expect(link.messages.at(-1)).toMatchObject({ expiresAt: 5_000 });
92
+ });
93
+ it('does not execute stale, expired, or cross-session/source commands', async () => {
94
+ const { registry, execute } = fixture();
95
+ const link = transport();
96
+ createDataSurfaceBrowserBridge({
97
+ registry,
98
+ transport: link,
99
+ sessionId: 'session-1',
100
+ source: 'browser-1',
101
+ peerSource: 'server-1',
102
+ now: () => 1_000,
103
+ });
104
+ await link.receive(request({ commandId: 'stale', expectedRevision: 99 }));
105
+ await link.receive(request({ commandId: 'expired', expiresAt: 500 }));
106
+ await link.receive(request({ commandId: 'session', sessionId: 'other' }));
107
+ await link.receive(request({ commandId: 'source', source: 'other' }));
108
+ await drain();
109
+ expect(execute).not.toHaveBeenCalled();
110
+ expect(link.messages
111
+ .filter((message) => message.type === 'data-surface.ack')
112
+ .map((message) => message.reason)).toEqual(expect.arrayContaining([
113
+ 'stale_revision',
114
+ 'expired',
115
+ 'session_mismatch',
116
+ 'source_mismatch',
117
+ ]));
118
+ });
119
+ it('rejects a command whose wire identity is accompanied by an unauthenticated peer', async () => {
120
+ const { registry, execute } = fixture();
121
+ const link = transport();
122
+ createDataSurfaceBrowserBridge({
123
+ registry,
124
+ transport: link,
125
+ sessionId: 'session-1',
126
+ source: 'browser-1',
127
+ peerSource: 'server-1',
128
+ now: () => 1_000,
129
+ });
130
+ await link.receive(request(), {
131
+ sessionId: 'session-1',
132
+ source: 'attacker',
133
+ });
134
+ await link.receive(request({ commandId: 'session-spoof' }), {
135
+ sessionId: 'attacker',
136
+ source: 'server-1',
137
+ });
138
+ expect(execute).not.toHaveBeenCalled();
139
+ expect(link.messages).toHaveLength(0);
140
+ });
141
+ it('rejects identifiers longer than the shared bridge contract', async () => {
142
+ const { registry, execute } = fixture();
143
+ const link = transport();
144
+ createDataSurfaceBrowserBridge({
145
+ registry,
146
+ transport: link,
147
+ sessionId: 'session-1',
148
+ source: 'browser-1',
149
+ peerSource: 'server-1',
150
+ now: () => 1_000,
151
+ });
152
+ const tooLong = 'x'.repeat(DATA_SURFACE_IDENTIFIER_MAX_LENGTH + 1);
153
+ await link.receive(request({ commandId: tooLong }));
154
+ await link.receive(request({ controlId: tooLong }));
155
+ await link.receive(request({ identity: { surfaceId: tooLong, kind: 'table' } }));
156
+ await link.receive(request({
157
+ identity: {
158
+ surfaceId: 'orders',
159
+ kind: 'table',
160
+ subject: { type: tooLong, id: 'docs' },
161
+ },
162
+ }));
163
+ await link.receive(request({
164
+ identity: {
165
+ surfaceId: 'orders',
166
+ kind: 'table',
167
+ subject: { type: 'site', id: tooLong },
168
+ },
169
+ }));
170
+ expect(execute).not.toHaveBeenCalled();
171
+ expect(link.messages).toHaveLength(0);
172
+ });
173
+ it('retains every unexpired replay outcome and refuses new commands at capacity', async () => {
174
+ const { registry, execute } = fixture();
175
+ const link = transport();
176
+ createDataSurfaceBrowserBridge({
177
+ registry,
178
+ transport: link,
179
+ sessionId: 'session-1',
180
+ source: 'browser-1',
181
+ peerSource: 'server-1',
182
+ now: () => 1_000,
183
+ maxReplayEntries: 2,
184
+ });
185
+ await link.receive(request({ commandId: 'one', expiresAt: 5_000 }));
186
+ await drain();
187
+ await link.receive(request({ commandId: 'two', expectedRevision: 2, expiresAt: 5_000 }));
188
+ await drain();
189
+ await link.receive(request({ commandId: 'three', expectedRevision: 3, expiresAt: 5_000 }));
190
+ await drain();
191
+ expect(execute).toHaveBeenCalledTimes(2);
192
+ expect(link.messages.at(-1)).toMatchObject({
193
+ commandId: 'three',
194
+ reason: 'replay_capacity_exceeded',
195
+ });
196
+ await link.receive(request({ commandId: 'one', expiresAt: 5_000 }));
197
+ await drain();
198
+ expect(execute).toHaveBeenCalledTimes(2);
199
+ });
200
+ it('atomically reserves replay capacity while concurrent executions are held', async () => {
201
+ const registry = createDataSurfaceRegistry();
202
+ const releases = [];
203
+ const execute = vi.fn(() => new Promise((resolve) => releases.push(resolve)));
204
+ registry.register({
205
+ descriptor,
206
+ getSnapshot: () => ({ revision: 1, state: {} }),
207
+ execute,
208
+ });
209
+ const link = transport();
210
+ createDataSurfaceBrowserBridge({
211
+ registry,
212
+ transport: link,
213
+ sessionId: 'session-1',
214
+ source: 'browser-1',
215
+ peerSource: 'server-1',
216
+ now: () => 1_000,
217
+ maxReplayEntries: 2,
218
+ });
219
+ void link.receive(request({ commandId: 'held-one', expiresAt: 5_000 }));
220
+ void link.receive(request({ commandId: 'held-two', expiresAt: 5_000 }));
221
+ void link.receive(request({ commandId: 'held-three', expiresAt: 5_000 }));
222
+ for (let attempt = 0; attempt < 5 && execute.mock.calls.length < 1; attempt++) {
223
+ await drain();
224
+ }
225
+ expect(execute).toHaveBeenCalledTimes(1);
226
+ expect(link.messages).toContainEqual(expect.objectContaining({
227
+ type: 'data-surface.ack',
228
+ commandId: 'held-three',
229
+ reason: 'replay_capacity_exceeded',
230
+ }));
231
+ releases[0]?.();
232
+ for (let attempt = 0; attempt < 5 && execute.mock.calls.length < 2; attempt++) {
233
+ await drain();
234
+ }
235
+ releases[1]?.();
236
+ await drain();
237
+ await drain();
238
+ expect(link.messages.filter((message) => message.type === 'data-surface.ack' && message.ok === true)).toHaveLength(2);
239
+ });
240
+ it('releases replay reservations when execution errors after expiry', async () => {
241
+ let current = 1_000;
242
+ const registry = createDataSurfaceRegistry();
243
+ const execute = vi
244
+ .fn()
245
+ .mockImplementationOnce(() => {
246
+ throw new Error('boom');
247
+ })
248
+ .mockImplementationOnce(() => undefined);
249
+ registry.register({
250
+ descriptor,
251
+ getSnapshot: () => ({ revision: 1, state: {} }),
252
+ execute,
253
+ });
254
+ const link = transport();
255
+ createDataSurfaceBrowserBridge({
256
+ registry,
257
+ transport: link,
258
+ sessionId: 'session-1',
259
+ source: 'browser-1',
260
+ peerSource: 'server-1',
261
+ now: () => current,
262
+ maxReplayEntries: 1,
263
+ });
264
+ await link.receive(request({ commandId: 'expired-error', expiresAt: 1_100 }));
265
+ await drain();
266
+ current = 1_200;
267
+ await link.receive(request({ commandId: 'after-error', expiresAt: 5_000 }));
268
+ await drain();
269
+ expect(execute).toHaveBeenCalledTimes(2);
270
+ expect(link.messages.at(-1)).toMatchObject({
271
+ commandId: 'after-error',
272
+ ok: true,
273
+ });
274
+ });
275
+ it('releases replay capacity when execution never settles before expiry', async () => {
276
+ vi.useFakeTimers();
277
+ try {
278
+ vi.setSystemTime(1_000);
279
+ const registry = createDataSurfaceRegistry();
280
+ let finishHung;
281
+ const execute = vi
282
+ .fn()
283
+ .mockImplementationOnce(() => new Promise((resolve) => {
284
+ finishHung = resolve;
285
+ }))
286
+ .mockImplementationOnce(() => undefined);
287
+ registry.register({
288
+ descriptor,
289
+ getSnapshot: () => ({ revision: 1, state: {} }),
290
+ execute,
291
+ });
292
+ registry.register({
293
+ descriptor: {
294
+ ...descriptor,
295
+ identity: { surfaceId: 'invoices', kind: 'table' },
296
+ },
297
+ getSnapshot: () => ({ revision: 1, state: {} }),
298
+ execute,
299
+ });
300
+ const link = transport();
301
+ createDataSurfaceBrowserBridge({
302
+ registry,
303
+ transport: link,
304
+ sessionId: 'session-1',
305
+ source: 'browser-1',
306
+ peerSource: 'server-1',
307
+ maxReplayEntries: 1,
308
+ });
309
+ const first = link.receive(request({ commandId: 'hung', expiresAt: 1_100 }));
310
+ await vi.advanceTimersByTimeAsync(100);
311
+ await first;
312
+ expect(link.messages.at(-1)).toMatchObject({
313
+ commandId: 'hung',
314
+ reason: 'expired',
315
+ });
316
+ finishHung();
317
+ await vi.runOnlyPendingTimersAsync();
318
+ expect(link.messages.filter((message) => message.type === 'data-surface.ack' && message.commandId === 'hung')).toHaveLength(1);
319
+ await link.receive(request({
320
+ commandId: 'after-hung',
321
+ expiresAt: 5_000,
322
+ identity: { surfaceId: 'invoices', kind: 'table' },
323
+ }));
324
+ await vi.runOnlyPendingTimersAsync();
325
+ expect(execute).toHaveBeenCalledTimes(2);
326
+ expect(link.messages.at(-1)).toMatchObject({
327
+ commandId: 'after-hung',
328
+ ok: true,
329
+ });
330
+ }
331
+ finally {
332
+ vi.useRealTimers();
333
+ }
334
+ });
335
+ it('forwards only monotonic registry events', async () => {
336
+ const { registry } = fixture();
337
+ const link = transport();
338
+ createDataSurfaceBrowserBridge({
339
+ registry,
340
+ transport: link,
341
+ sessionId: 'session-1',
342
+ source: 'browser-1',
343
+ peerSource: 'server-1',
344
+ });
345
+ await link.receive(request({ commandId: 'event-command', expiresAt: Date.now() + 1000 }));
346
+ await drain();
347
+ const events = link.messages.filter((message) => message.type === 'data-surface.event');
348
+ expect(events.length).toBeGreaterThan(0);
349
+ expect(events.map((event) => event.sequence)).toEqual([...events].map((event) => event.sequence).sort((a, b) => a - b));
350
+ });
351
+ });
@@ -233,6 +233,17 @@ onMount(() => {
233
233
  }
234
234
  },
235
235
  getValue: () => value,
236
+ constraints: { required },
237
+ webMcpSchema: {
238
+ type: 'object',
239
+ properties: Object.fromEntries(
240
+ fields.map((field) => [field, { type: 'string' }]),
241
+ ),
242
+ ...(required ? { required: [...fields] } : {}),
243
+ },
244
+ validate: () =>
245
+ !required ||
246
+ fields.every((field) => String(value[field] ?? '').trim().length > 0),
236
247
  };
237
248
  formContext.registerField(fieldDef);
238
249
  }
@@ -1 +1 @@
1
- {"version":3,"file":"AddressInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/AddressInput.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,KAAK,YAAY,GAAG,MAAM,YAAY,CAAC;AAEvC,MAAM,WAAW,KAAK;IACpB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC9B,2BAA2B;IAC3B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,0CAA0C;IAC1C,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;CACnD;AAwTD,QAAA,MAAM,YAAY,gDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"AddressInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/AddressInput.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,KAAK,YAAY,GAAG,MAAM,YAAY,CAAC;AAEvC,MAAM,WAAW,KAAK;IACpB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC9B,2BAA2B;IAC3B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,0CAA0C;IAC1C,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;CACnD;AAmUD,QAAA,MAAM,YAAY,gDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -64,6 +64,8 @@ onMount(() => {
64
64
  }
65
65
  },
66
66
  getValue: () => checked,
67
+ constraints: { required },
68
+ validate: () => !required || checked,
67
69
  };
68
70
  formContext.registerField(fieldDef);
69
71
  }
@@ -206,4 +208,4 @@ function handleChange(e: Event) {
206
208
  background-color: var(--smrt-color-on-surface);
207
209
  opacity: 0.08;
208
210
  }
209
- </style>
211
+ </style>
@@ -1 +1 @@
1
- {"version":3,"file":"CheckboxInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CheckboxInput.svelte.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAwFD,QAAA,MAAM,aAAa,kDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"CheckboxInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/CheckboxInput.svelte.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AA2FD,QAAA,MAAM,aAAa,kDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -206,6 +206,9 @@ onMount(() => {
206
206
  .catch(() => {});
207
207
  },
208
208
  getValue: () => ({ startDate, endDate }),
209
+ constraints: { required },
210
+ validate: () =>
211
+ !required || (startDate.trim().length > 0 && endDate.trim().length > 0),
209
212
  };
210
213
  formContext.registerField(fieldDef);
211
214
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AAkYD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"DateRangeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateRangeInput.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6BAA6B;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2BAA2B;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC5C;AAqYD,QAAA,MAAM,cAAc,gEAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -122,6 +122,8 @@ onMount(() => {
122
122
  updateValue(String(v ?? ''));
123
123
  },
124
124
  getValue: () => value,
125
+ constraints: { required },
126
+ validate: () => !required || value.trim().length > 0,
125
127
  };
126
128
  formContext.registerField(fieldDef);
127
129
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DateTimeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateTimeInput.svelte.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA6UD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"DateTimeInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DateTimeInput.svelte.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,KAAK;IACpB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AA+UD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}