@happyvertical/smrt-svelte 0.45.2 → 0.46.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.
@@ -0,0 +1,1392 @@
1
+ /**
2
+ * Application-level data-surface conformance gate (#2450).
3
+ *
4
+ * This fixture deliberately composes the shipped registry, browser/server
5
+ * bridge, principal tools, action adapter, ContentList descriptor, and report
6
+ * adapter. The database and generated REST handler are real; only the bridge
7
+ * transport is an in-process test double.
8
+ */
9
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
10
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
11
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
12
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
13
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
14
+ };
15
+ import { createDataSurfaceTools, DATA_DISCOVER_TOOL_SLUG, DATA_QUERY_TOOL_SLUG, DataSurfaceDeadlineError, DataSurfaceQueryError, DataSurfaceRequestError, executeAsPrincipal, } from '@happyvertical/smrt-agents';
16
+ import { createDataSurfaceActionAdapter, InMemoryDataSurfaceActionStateStore, } from '@happyvertical/smrt-agents/server';
17
+ import { createDataSurfaceCommandBridge, } from '@happyvertical/smrt-chat/data-surface-bridge';
18
+ import { buildContentListSurfaceDescriptor, CONTENT_LIST_SCHEMA_VERSION, ContentList, } from '@happyvertical/smrt-content/svelte';
19
+ import { APIGenerator, field, getTestDatabase, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
20
+ import { buildReportAdapterDescriptor, groupBy, queryReportMaterializedRows, report, } from '@happyvertical/smrt-reports';
21
+ import { disableTenancy, enableTenancy, withTenant, } from '@happyvertical/smrt-tenancy';
22
+ import { createDataSurfaceRegistry, createDataTableController, DataTable, } from '@happyvertical/smrt-ui/data';
23
+ import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
24
+ import { registerPermissionDefinitions } from '@happyvertical/smrt-users';
25
+ import { render, screen } from '@testing-library/svelte';
26
+ import userEvent from '@testing-library/user-event';
27
+ import { tick } from 'svelte';
28
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
29
+ let SurfaceConformanceRecord = class SurfaceConformanceRecord extends SmrtObject {
30
+ tenantId = '';
31
+ name = '';
32
+ status = 'active';
33
+ secret = '';
34
+ constructor(options = {}) {
35
+ super(options);
36
+ if (options.tenantId !== undefined)
37
+ this.tenantId = options.tenantId;
38
+ if (options.name !== undefined)
39
+ this.name = options.name;
40
+ }
41
+ };
42
+ __decorate([
43
+ field({ type: 'text' })
44
+ ], SurfaceConformanceRecord.prototype, "tenantId", void 0);
45
+ __decorate([
46
+ field({ type: 'text' })
47
+ ], SurfaceConformanceRecord.prototype, "name", void 0);
48
+ __decorate([
49
+ field({ type: 'text' })
50
+ ], SurfaceConformanceRecord.prototype, "status", void 0);
51
+ __decorate([
52
+ field({ type: 'text', sensitive: true })
53
+ ], SurfaceConformanceRecord.prototype, "secret", void 0);
54
+ SurfaceConformanceRecord = __decorate([
55
+ smrt({
56
+ api: { include: ['list', 'get', 'update'] },
57
+ tenantScoped: { mode: 'required' },
58
+ })
59
+ ], SurfaceConformanceRecord);
60
+ class SurfaceConformanceRecordCollection extends SmrtCollection {
61
+ static _itemClass = SurfaceConformanceRecord;
62
+ }
63
+ let SurfaceConformanceReport = class SurfaceConformanceReport extends SmrtObject {
64
+ name = '';
65
+ };
66
+ __decorate([
67
+ field({ type: 'text' }),
68
+ groupBy('name')
69
+ ], SurfaceConformanceReport.prototype, "name", void 0);
70
+ SurfaceConformanceReport = __decorate([
71
+ smrt({ tenantScoped: { mode: 'required' } }),
72
+ report({ source: SurfaceConformanceRecord })
73
+ ], SurfaceConformanceReport);
74
+ const identity = {
75
+ surfaceId: 'surface-conformance-records',
76
+ kind: 'table',
77
+ subject: { type: 'tenant', id: 'tenant-a' },
78
+ };
79
+ const descriptor = {
80
+ version: 1,
81
+ identity,
82
+ schemaVersion: 1,
83
+ label: 'Records',
84
+ rowKey: 'id',
85
+ columns: [
86
+ {
87
+ id: 'id',
88
+ label: 'ID',
89
+ capabilities: ['read', 'project'],
90
+ role: 'row-key',
91
+ },
92
+ {
93
+ id: 'name',
94
+ label: 'Name',
95
+ capabilities: ['read', 'search', 'filter', 'sort', 'project'],
96
+ },
97
+ {
98
+ id: 'status',
99
+ label: 'Status',
100
+ capabilities: ['read', 'filter', 'sort', 'project'],
101
+ },
102
+ { id: 'secret', label: 'Secret', sensitivity: 'secret', capabilities: [] },
103
+ ],
104
+ query: {
105
+ modes: ['rows', 'count'],
106
+ projectableColumnIds: ['id', 'name', 'status'],
107
+ searchableColumnIds: ['name'],
108
+ filterableColumnIds: ['name', 'status'],
109
+ sortableColumnIds: ['name', 'status'],
110
+ },
111
+ controls: [
112
+ { id: 'set-search', label: 'Search' },
113
+ { id: 'toggle-sorting', label: 'Sort' },
114
+ { id: 'set-page', label: 'Page' },
115
+ ],
116
+ actions: [
117
+ {
118
+ id: 'archive',
119
+ label: 'Archive',
120
+ sensitivity: 'sensitive',
121
+ selectionScopes: ['explicit-ids'],
122
+ requiresConfirmation: true,
123
+ },
124
+ ],
125
+ limits: { maxQueryRows: 2, maxQueryBytes: 20_000, maxSelectionSize: 10 },
126
+ };
127
+ // A mounted table can only expose columns that its current principal may
128
+ // render. The server descriptor retains the secret field for redaction tests;
129
+ // the mounted descriptor proves the UI contract cannot accidentally render it.
130
+ const mountedDescriptor = {
131
+ ...descriptor,
132
+ columns: descriptor.columns
133
+ .filter((column) => column.id !== 'secret')
134
+ .map(({ role: _role, ...column }) => column),
135
+ query: {
136
+ modes: descriptor.query.modes,
137
+ projectableColumnIds: descriptor.query.projectableColumnIds,
138
+ },
139
+ controls: [
140
+ ...descriptor.controls,
141
+ { id: 'set-filters', label: 'Filter' },
142
+ { id: 'set-column-order', label: 'Columns' },
143
+ { id: 'set-column-visibility', label: 'Column visibility' },
144
+ { id: 'set-selected-rows', label: 'Selection' },
145
+ ],
146
+ };
147
+ function bridgeTransport() {
148
+ const messages = [];
149
+ const listeners = new Set();
150
+ const statuses = new Set();
151
+ return {
152
+ messages,
153
+ send: vi.fn((message) => {
154
+ messages.push(message);
155
+ }),
156
+ subscribe(listener) {
157
+ listeners.add(listener);
158
+ return () => listeners.delete(listener);
159
+ },
160
+ subscribeStatus(listener) {
161
+ statuses.add(listener);
162
+ return () => statuses.delete(listener);
163
+ },
164
+ receive(message, peer) {
165
+ for (const listener of listeners)
166
+ listener(message, peer);
167
+ },
168
+ status(state) {
169
+ for (const listener of statuses)
170
+ listener(state);
171
+ },
172
+ };
173
+ }
174
+ function requiredTool(tools, slug) {
175
+ const tool = tools.find((candidate) => candidate.slug === slug);
176
+ if (!tool)
177
+ throw new Error(`Missing fixture tool: ${slug}`);
178
+ return tool;
179
+ }
180
+ describe('data-surface human/agent parity and security (#2450)', () => {
181
+ let db;
182
+ let records;
183
+ let firstId = '';
184
+ let secondId = '';
185
+ let hiddenId = '';
186
+ let releasePermissions;
187
+ beforeAll(async () => {
188
+ db = await getTestDatabase({
189
+ type: 'sqlite',
190
+ url: ':memory:',
191
+ classes: ['SurfaceConformanceRecord'],
192
+ });
193
+ enableTenancy();
194
+ releasePermissions = registerPermissionDefinitions([
195
+ { slug: 'records.read' },
196
+ { slug: 'records.update' },
197
+ ]);
198
+ records = await SurfaceConformanceRecordCollection.create({ db });
199
+ const first = await withTenant({ tenantId: 'tenant-a' }, () => records.create({ tenantId: 'tenant-a', name: 'Ada' }));
200
+ const second = await withTenant({ tenantId: 'tenant-a' }, () => records.create({ tenantId: 'tenant-a', name: 'Grace' }));
201
+ const hidden = await withTenant({ tenantId: 'tenant-b' }, () => records.create({ tenantId: 'tenant-b', name: 'Hidden tenant' }));
202
+ firstId = String(first.id);
203
+ secondId = String(second.id);
204
+ hiddenId = String(hidden.id);
205
+ await withTenant({ tenantId: 'tenant-a' }, async () => {
206
+ first.secret = 'sensitive-sentinel-tenant-a';
207
+ second.secret = 'sensitive-sentinel-tenant-a-2';
208
+ await first.save();
209
+ await second.save();
210
+ });
211
+ await withTenant({ tenantId: 'tenant-b' }, async () => {
212
+ hidden.secret = 'sensitive-sentinel-tenant-b';
213
+ await hidden.save();
214
+ });
215
+ });
216
+ afterAll(async () => {
217
+ disableTenancy();
218
+ releasePermissions?.();
219
+ await db?.close?.();
220
+ });
221
+ function surface(options = {}) {
222
+ return {
223
+ id: 'records',
224
+ collection: 'records',
225
+ schema: {
226
+ version: 1,
227
+ identityField: 'id',
228
+ fields: [
229
+ { id: 'id', type: 'string', projectable: true, sortable: true },
230
+ {
231
+ id: 'name',
232
+ type: 'string',
233
+ projectable: true,
234
+ sortable: true,
235
+ filterOperators: ['eq'],
236
+ },
237
+ {
238
+ id: 'status',
239
+ type: 'string',
240
+ projectable: true,
241
+ sortable: true,
242
+ filterOperators: ['eq'],
243
+ },
244
+ {
245
+ id: 'tenantId',
246
+ type: 'string',
247
+ projectable: true,
248
+ sensitive: true,
249
+ },
250
+ { id: 'secret', type: 'string', projectable: true, sensitive: true },
251
+ ],
252
+ defaultPageLimit: 2,
253
+ maxPageLimit: 2,
254
+ maxResultBytes: 20_000,
255
+ supports: { cursorPagination: true },
256
+ },
257
+ execute: async (_surface, request, context) => {
258
+ const all = await records.list();
259
+ const scoped = all
260
+ .filter((row) => row.tenantId === context.principal.tenantId)
261
+ .sort((left, right) => left.name.localeCompare(right.name));
262
+ const cursorPage = request.page?.kind === 'cursor' ? request.page : undefined;
263
+ const offset = request.page?.kind === 'offset'
264
+ ? request.page.offset
265
+ : cursorPage?.after
266
+ ? scoped.findIndex((row) => row.id === cursorPage.after) + 1
267
+ : 0;
268
+ const limit = request.page ? request.page.limit : scoped.length;
269
+ const rows = scoped.slice(offset, offset + limit).map((row) => {
270
+ const projection = new Set(request.projection ?? []);
271
+ return {
272
+ id: row.id,
273
+ ...(projection.has('name') ? { name: row.name } : {}),
274
+ ...(projection.has('status') ? { status: row.status } : {}),
275
+ };
276
+ });
277
+ return {
278
+ rows,
279
+ page: cursorPage
280
+ ? {
281
+ kind: 'cursor',
282
+ limit,
283
+ hasMore: offset + rows.length < scoped.length,
284
+ ...(offset + rows.length < scoped.length && rows.length > 0
285
+ ? { nextCursor: String(rows.at(-1)?.id) }
286
+ : {}),
287
+ }
288
+ : {
289
+ kind: 'offset',
290
+ offset,
291
+ limit,
292
+ hasMore: offset + rows.length < scoped.length,
293
+ },
294
+ total: { kind: 'exact', value: scoped.length },
295
+ freshness: {
296
+ state: 'fresh',
297
+ asOf: '2026-08-30T00:00:00.000Z',
298
+ },
299
+ warnings: [],
300
+ truncated: options.truncated ?? false,
301
+ };
302
+ },
303
+ };
304
+ }
305
+ function principalOptions(tenantId, allowedTools) {
306
+ return {
307
+ db,
308
+ principal: {
309
+ runAsUserId: 'surface-user',
310
+ tenantId,
311
+ allowedTools,
312
+ },
313
+ permissions: ['records.read', 'records.update'],
314
+ postgresRls: false,
315
+ audit: () => undefined,
316
+ };
317
+ }
318
+ it('uses the real DB-backed principal query and REST auth boundary', async () => {
319
+ const tools = createDataSurfaceTools({ surfaces: [surface()] });
320
+ const discovered = await executeAsPrincipal(principalOptions('tenant-a', [
321
+ DATA_DISCOVER_TOOL_SLUG,
322
+ DATA_QUERY_TOOL_SLUG,
323
+ ]), (run) => requiredTool(tools, DATA_DISCOVER_TOOL_SLUG).execute({
324
+ run,
325
+ args: {},
326
+ db,
327
+ }));
328
+ expect(discovered).toMatchObject([
329
+ { id: 'records', fields: expect.any(Array) },
330
+ ]);
331
+ const fields = discovered[0]
332
+ .fields;
333
+ expect(fields.map((field) => field.id)).toEqual(['id', 'name', 'status']);
334
+ const queried = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(tools, DATA_QUERY_TOOL_SLUG).execute({
335
+ run,
336
+ args: {
337
+ surfaceId: 'records',
338
+ request: {
339
+ version: 1,
340
+ requestId: 'principal-page-1',
341
+ mode: 'rows',
342
+ projection: ['id', 'name', 'status'],
343
+ page: { kind: 'offset', offset: 0, limit: 1 },
344
+ },
345
+ },
346
+ db,
347
+ }));
348
+ expect(queried).toMatchObject({
349
+ rows: [{ id: firstId, name: 'Ada' }],
350
+ total: { kind: 'exact', value: 2 },
351
+ freshness: { state: 'fresh' },
352
+ });
353
+ expect(JSON.stringify(queried)).not.toContain('tenant-b');
354
+ let cursorFailure;
355
+ const cursorTools = createDataSurfaceTools({
356
+ surfaces: [surface()],
357
+ onFailure: (entry) => {
358
+ cursorFailure = entry.error;
359
+ },
360
+ });
361
+ const cursorQueried = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(cursorTools, DATA_QUERY_TOOL_SLUG).execute({
362
+ run,
363
+ args: {
364
+ surfaceId: 'records',
365
+ request: {
366
+ version: 1,
367
+ requestId: 'principal-cursor-1',
368
+ mode: 'rows',
369
+ projection: ['id', 'name'],
370
+ page: { kind: 'cursor', limit: 1 },
371
+ },
372
+ },
373
+ db,
374
+ }));
375
+ expect(cursorFailure).toBeUndefined();
376
+ expect(cursorQueried).toMatchObject({
377
+ rows: [{ id: firstId }],
378
+ page: { kind: 'cursor', limit: 1, hasMore: true, nextCursor: firstId },
379
+ });
380
+ const bounded = createDataSurfaceTools({
381
+ surfaces: [surface({ truncated: true })],
382
+ });
383
+ const boundedResult = await executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(bounded, DATA_QUERY_TOOL_SLUG).execute({
384
+ run,
385
+ args: {
386
+ surfaceId: 'records',
387
+ request: {
388
+ version: 1,
389
+ requestId: 'principal-truncated',
390
+ mode: 'rows',
391
+ projection: ['id', 'name'],
392
+ page: { kind: 'offset', offset: 0, limit: 1 },
393
+ },
394
+ },
395
+ db,
396
+ }));
397
+ expect(boundedResult).toMatchObject({ truncated: true, warnings: [] });
398
+ // Sensitive fields are adversarially requested at the principal boundary;
399
+ // visibleSchema rejects them before the executor can read or serialize a
400
+ // sentinel value.
401
+ await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(tools, DATA_QUERY_TOOL_SLUG).execute({
402
+ run,
403
+ args: {
404
+ surfaceId: 'records',
405
+ request: {
406
+ version: 1,
407
+ requestId: 'principal-sensitive-probe',
408
+ mode: 'rows',
409
+ projection: ['id', 'secret'],
410
+ page: { kind: 'offset', offset: 0, limit: 1 },
411
+ },
412
+ },
413
+ db,
414
+ }))).rejects.toBeInstanceOf(DataSurfaceRequestError);
415
+ // Exercise the result-byte gate with a deliberately oversized executor
416
+ // payload. A safe projection alone would not prove this bound is active.
417
+ const oversized = surface();
418
+ oversized.schema = { ...oversized.schema, maxResultBytes: 120 };
419
+ oversized.execute = async () => ({
420
+ rows: [{ id: firstId, name: 'x'.repeat(500) }],
421
+ total: { kind: 'exact', value: 1 },
422
+ page: { kind: 'offset', offset: 0, limit: 1, hasMore: false },
423
+ freshness: { state: 'fresh' },
424
+ warnings: [],
425
+ truncated: false,
426
+ });
427
+ await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => {
428
+ const oversizedTools = createDataSurfaceTools({
429
+ surfaces: [oversized],
430
+ });
431
+ return requiredTool(oversizedTools, DATA_QUERY_TOOL_SLUG).execute({
432
+ run,
433
+ args: {
434
+ surfaceId: 'records',
435
+ request: {
436
+ version: 1,
437
+ requestId: 'principal-byte-bound',
438
+ mode: 'rows',
439
+ projection: ['id', 'name'],
440
+ page: { kind: 'offset', offset: 0, limit: 1 },
441
+ },
442
+ },
443
+ db,
444
+ });
445
+ })).rejects.toBeInstanceOf(DataSurfaceQueryError);
446
+ // The executor must not be able to return more rows than the requested
447
+ // bounded page, even when the caller asks for a valid limit.
448
+ const overRows = surface();
449
+ overRows.execute = async () => [
450
+ { id: firstId, name: 'Ada' },
451
+ { id: secondId, name: 'Grace' },
452
+ { id: hiddenId, name: 'Hidden tenant' },
453
+ ];
454
+ await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => {
455
+ const overRowsTools = createDataSurfaceTools({
456
+ surfaces: [overRows],
457
+ });
458
+ return requiredTool(overRowsTools, DATA_QUERY_TOOL_SLUG).execute({
459
+ run,
460
+ args: {
461
+ surfaceId: 'records',
462
+ request: {
463
+ version: 1,
464
+ requestId: 'principal-row-bound',
465
+ mode: 'rows',
466
+ projection: ['id', 'name'],
467
+ page: { kind: 'offset', offset: 0, limit: 2 },
468
+ },
469
+ },
470
+ db,
471
+ });
472
+ })).rejects.toBeInstanceOf(DataSurfaceQueryError);
473
+ let aborted = false;
474
+ const slow = surface({});
475
+ slow.execute = async (_surface, _request, context) => {
476
+ context.signal.addEventListener('abort', () => {
477
+ aborted = true;
478
+ }, { once: true });
479
+ return new Promise(() => undefined);
480
+ };
481
+ const slowTools = createDataSurfaceTools({
482
+ surfaces: [slow],
483
+ deadlineMs: 1,
484
+ });
485
+ await expect(executeAsPrincipal(principalOptions('tenant-a', [DATA_QUERY_TOOL_SLUG]), (run) => requiredTool(slowTools, DATA_QUERY_TOOL_SLUG).execute({
486
+ run,
487
+ args: {
488
+ surfaceId: 'records',
489
+ request: {
490
+ version: 1,
491
+ requestId: 'principal-timeout',
492
+ mode: 'rows',
493
+ projection: ['id', 'name'],
494
+ page: { kind: 'offset', offset: 0, limit: 1 },
495
+ },
496
+ },
497
+ db,
498
+ }))).rejects.toBeInstanceOf(DataSurfaceDeadlineError);
499
+ expect(aborted).toBe(true);
500
+ // Model the production request boundary: authentication resolves a
501
+ // trusted session first, then the server binds both the principal and its
502
+ // tenant before entering the generated REST handler. The handler itself
503
+ // still runs its configured auth middleware, so a request cannot swap
504
+ // credentials after the session context has been selected.
505
+ const fixtureSessions = new Map([
506
+ ['Bearer fixture-user', { userId: 'surface-user', tenantId: 'tenant-a' }],
507
+ [
508
+ 'Bearer fixture-user-b',
509
+ { userId: 'surface-user-b', tenantId: 'tenant-b' },
510
+ ],
511
+ ]);
512
+ const handleRestRequest = async (request) => {
513
+ const token = request.headers.get('authorization');
514
+ if (!token)
515
+ return new Response('auth required', { status: 401 });
516
+ const session = fixtureSessions.get(token);
517
+ if (!session)
518
+ return new Response('forbidden', { status: 403 });
519
+ const api = new APIGenerator({
520
+ basePath: '/api/v1',
521
+ authMiddleware: () => async (authenticatedRequest) => {
522
+ if (authenticatedRequest.headers.get('authorization') !== token) {
523
+ return new Response('forbidden', { status: 403 });
524
+ }
525
+ return authenticatedRequest;
526
+ },
527
+ }, {
528
+ db,
529
+ user: { id: session.userId },
530
+ permissions: ['records.read', 'records.update'],
531
+ });
532
+ api.registerCollection('surfaceconformancerecords', records);
533
+ return withTenant({ tenantId: session.tenantId }, () => api.generateHandler()(request));
534
+ };
535
+ expect((await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords'))).status).toBe(401);
536
+ expect((await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
537
+ headers: { authorization: 'Bearer other' },
538
+ }))).status).toBe(403);
539
+ const restResponse = await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
540
+ headers: { authorization: 'Bearer fixture-user' },
541
+ }));
542
+ expect(restResponse.status).toBe(200);
543
+ const restBody = await restResponse.json();
544
+ expect(JSON.stringify(restBody)).toContain(firstId);
545
+ expect(JSON.stringify(restBody)).not.toContain(hiddenId);
546
+ expect(JSON.stringify(restBody)).not.toContain('Hidden tenant');
547
+ expect(JSON.stringify(restBody)).not.toContain('sensitive-sentinel');
548
+ const otherTenantResponse = await handleRestRequest(new Request('http://fixture.local/api/v1/surfaceconformancerecords', {
549
+ headers: { authorization: 'Bearer fixture-user-b' },
550
+ }));
551
+ expect(otherTenantResponse.status).toBe(200);
552
+ const otherTenantBody = await otherTenantResponse.json();
553
+ expect(JSON.stringify(otherTenantBody)).toContain(hiddenId);
554
+ expect(JSON.stringify(otherTenantBody)).not.toContain(firstId);
555
+ expect(JSON.stringify(otherTenantBody)).not.toContain('Ada');
556
+ expect(JSON.stringify(otherTenantBody)).not.toContain('sensitive-sentinel');
557
+ });
558
+ it('keeps human and browser command snapshots identical and rejects bridge abuse', async () => {
559
+ const registry = createDataSurfaceRegistry();
560
+ const controller = createDataTableController({
561
+ columnIds: ['id', 'name', 'status'],
562
+ });
563
+ const browserIdentity = {
564
+ ...identity,
565
+ surfaceId: 'surface-conformance-browser',
566
+ };
567
+ const browserController = createDataTableController({
568
+ columnIds: ['id', 'name', 'status'],
569
+ });
570
+ const { container } = render(DataTable, {
571
+ props: {
572
+ data: [
573
+ { id: firstId, name: 'Ada', status: 'active' },
574
+ { id: secondId, name: 'Grace', status: 'active' },
575
+ ],
576
+ columns: [
577
+ { id: 'id', label: 'ID', accessor: 'id' },
578
+ {
579
+ id: 'name',
580
+ label: 'Name',
581
+ accessor: 'name',
582
+ sortable: true,
583
+ filterable: true,
584
+ },
585
+ {
586
+ id: 'status',
587
+ label: 'Status',
588
+ accessor: 'status',
589
+ sortable: true,
590
+ filterable: true,
591
+ },
592
+ ],
593
+ rowKey: 'id',
594
+ sortable: true,
595
+ selectable: true,
596
+ controller,
597
+ caption: 'Records',
598
+ dataSurface: { registry, descriptor: mountedDescriptor },
599
+ },
600
+ });
601
+ render(DataTable, {
602
+ props: {
603
+ data: [
604
+ { id: firstId, name: 'Ada', status: 'active' },
605
+ { id: secondId, name: 'Grace', status: 'active' },
606
+ ],
607
+ columns: [
608
+ { id: 'id', label: 'ID', accessor: 'id' },
609
+ {
610
+ id: 'name',
611
+ label: 'Name',
612
+ accessor: 'name',
613
+ sortable: true,
614
+ filterable: true,
615
+ },
616
+ {
617
+ id: 'status',
618
+ label: 'Status',
619
+ accessor: 'status',
620
+ sortable: true,
621
+ filterable: true,
622
+ },
623
+ ],
624
+ rowKey: 'id',
625
+ sortable: true,
626
+ selectable: true,
627
+ controller: browserController,
628
+ caption: 'Records browser mirror',
629
+ dataSurface: {
630
+ registry,
631
+ descriptor: { ...mountedDescriptor, identity: browserIdentity },
632
+ },
633
+ },
634
+ });
635
+ await tick();
636
+ await vi.waitFor(() => {
637
+ expect(registry.inspect(identity)).toBeDefined();
638
+ expect(registry.inspect(browserIdentity)).toBeDefined();
639
+ });
640
+ const sortButton = container.querySelector('button[aria-label="Sort Name ascending"]');
641
+ if (!(sortButton instanceof HTMLElement)) {
642
+ throw new Error('Mounted human table did not render its sort button');
643
+ }
644
+ await userEvent.click(sortButton);
645
+ const sortedHuman = registry.inspect(identity);
646
+ expect(sortedHuman?.state).toMatchObject({
647
+ table: { state: { sorting: [{ columnId: 'name', direction: 'asc' }] } },
648
+ });
649
+ await expectNoA11yViolations(container);
650
+ const browserSort = await registry.execute({
651
+ version: 1,
652
+ commandId: 'browser-sort',
653
+ identity: browserIdentity,
654
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
655
+ controlId: 'toggle-sorting',
656
+ payload: { columnId: 'name' },
657
+ });
658
+ expect(browserSort).toMatchObject({ ok: true });
659
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
660
+ // Apply each remaining command once through the mounted human controller
661
+ // and once through the mounted browser registry. Separate controllers make
662
+ // this a real parity check rather than a second no-op against one state.
663
+ controller.dispatch({
664
+ type: 'setFilters',
665
+ filters: [{ columnId: 'name', operator: 'equals', value: 'Ada' }],
666
+ });
667
+ const browserFilter = await registry.execute({
668
+ version: 1,
669
+ commandId: 'browser-filter',
670
+ identity: browserIdentity,
671
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
672
+ controlId: 'set-filters',
673
+ payload: {
674
+ filters: [{ columnId: 'name', operator: 'equals', value: 'Ada' }],
675
+ },
676
+ });
677
+ expect(browserFilter).toMatchObject({ ok: true });
678
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
679
+ controller.dispatch({
680
+ type: 'setColumnOrder',
681
+ columnIds: ['status', 'name', 'id'],
682
+ });
683
+ const browserColumns = await registry.execute({
684
+ version: 1,
685
+ commandId: 'browser-columns',
686
+ identity: browserIdentity,
687
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
688
+ controlId: 'set-column-order',
689
+ payload: { columnIds: ['status', 'name', 'id'] },
690
+ });
691
+ expect(browserColumns).toMatchObject({ ok: true });
692
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
693
+ controller.dispatch({ type: 'setPage', page: 2 });
694
+ const browserPage = await registry.execute({
695
+ version: 1,
696
+ commandId: 'browser-page',
697
+ identity: browserIdentity,
698
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
699
+ controlId: 'set-page',
700
+ payload: { page: 2 },
701
+ });
702
+ expect(browserPage).toMatchObject({ ok: true });
703
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
704
+ const visibleColumns = [
705
+ { columnId: 'id', visible: false },
706
+ { columnId: 'name', visible: true },
707
+ { columnId: 'status', visible: true },
708
+ ];
709
+ controller.dispatch({
710
+ type: 'setColumnVisibility',
711
+ columns: visibleColumns,
712
+ });
713
+ const browserVisibility = await registry.execute({
714
+ version: 1,
715
+ commandId: 'browser-visibility',
716
+ identity: browserIdentity,
717
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
718
+ controlId: 'set-column-visibility',
719
+ payload: { columns: visibleColumns },
720
+ });
721
+ expect(browserVisibility).toMatchObject({ ok: true });
722
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
723
+ controller.dispatch({
724
+ type: 'toggleRowSelection',
725
+ rowId: firstId,
726
+ });
727
+ const browserSelection = await registry.execute({
728
+ version: 1,
729
+ commandId: 'browser-selection',
730
+ identity: browserIdentity,
731
+ expectedRevision: registry.inspect(browserIdentity)?.revision ?? 0,
732
+ controlId: 'set-selected-rows',
733
+ payload: { rowIds: [firstId] },
734
+ });
735
+ expect(browserSelection).toMatchObject({ ok: true });
736
+ expect(controller.snapshot()).toEqual(browserController.snapshot());
737
+ expect(browserSelection.snapshot?.selection).toEqual({
738
+ scope: 'explicit-ids',
739
+ rowIds: [firstId],
740
+ });
741
+ const human = registry.inspect(identity);
742
+ await expect(registry.execute({
743
+ version: 1,
744
+ commandId: 'stale-command',
745
+ identity,
746
+ expectedRevision: (human?.revision ?? 0) - 1,
747
+ controlId: 'toggle-sorting',
748
+ payload: { columnId: 'status' },
749
+ })).resolves.toMatchObject({ ok: false, reason: 'stale_revision' });
750
+ const replayCommand = {
751
+ version: 1,
752
+ commandId: 'replay-command',
753
+ identity,
754
+ expectedRevision: human?.revision ?? 0,
755
+ controlId: 'toggle-sorting',
756
+ payload: { columnId: 'status' },
757
+ };
758
+ const replayed = await registry.execute(replayCommand);
759
+ expect(replayed).toMatchObject({
760
+ ok: true,
761
+ revision: (human?.revision ?? 0) + 1,
762
+ });
763
+ await expect(registry.execute(replayCommand)).resolves.toEqual(replayed);
764
+ await expect(registry.execute({
765
+ ...replayCommand,
766
+ payload: { columnId: 'name' },
767
+ })).resolves.toMatchObject({ ok: false, reason: 'idempotency_conflict' });
768
+ await expect(registry.execute({
769
+ ...replayCommand,
770
+ commandId: 'absent-command',
771
+ identity: { ...identity, surfaceId: 'surface-that-is-not-mounted' },
772
+ expectedRevision: 0,
773
+ })).resolves.toMatchObject({ ok: false, reason: 'not_found' });
774
+ const ackLink = bridgeTransport();
775
+ ackLink.send.mockImplementation((message) => {
776
+ ackLink.messages.push(message);
777
+ if (message.type !== 'data-surface.command')
778
+ return;
779
+ void registry
780
+ .execute({
781
+ version: 1,
782
+ commandId: message.commandId,
783
+ identity: message.identity,
784
+ expectedRevision: message.expectedRevision,
785
+ controlId: message.controlId,
786
+ ...(message.payload === undefined
787
+ ? {}
788
+ : { payload: message.payload }),
789
+ })
790
+ .then((result) => {
791
+ ackLink.receive({
792
+ type: 'data-surface.ack',
793
+ version: 1,
794
+ commandId: message.commandId,
795
+ sessionId: message.sessionId,
796
+ source: 'browser-1',
797
+ expiresAt: message.expiresAt,
798
+ identity: message.identity,
799
+ expectedRevision: message.expectedRevision,
800
+ ok: result.ok,
801
+ ...(result.revision === undefined
802
+ ? {}
803
+ : { revision: result.revision }),
804
+ ...(result.snapshot === undefined
805
+ ? {}
806
+ : { snapshot: result.snapshot }),
807
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
808
+ }, { sessionId: 'session-1', source: 'browser-1' });
809
+ });
810
+ });
811
+ const ackBridge = createDataSurfaceCommandBridge({
812
+ transport: ackLink,
813
+ sessionId: 'session-1',
814
+ source: 'server-1',
815
+ peerSource: 'browser-1',
816
+ authorize: () => true,
817
+ now: () => 1_000,
818
+ ttlMs: 1_000,
819
+ timeoutMs: 1_000,
820
+ });
821
+ const browserAck = await ackBridge.send({
822
+ version: 1,
823
+ commandId: 'browser-state-ack',
824
+ identity,
825
+ expectedRevision: replayed.revision ?? 0,
826
+ controlId: 'set-page',
827
+ payload: { page: 2 },
828
+ });
829
+ expect(browserAck).toMatchObject({
830
+ ok: true,
831
+ revision: expect.any(Number),
832
+ });
833
+ expect(browserAck.snapshot).toEqual(registry.inspect(identity));
834
+ ackBridge.dispose();
835
+ // ACKs are accepted only from the bound transport peer and only when
836
+ // every correlation field still describes the pending command. Exercise
837
+ // those checks through the transport, rather than calling bridge internals.
838
+ const adversarialLink = bridgeTransport();
839
+ let bridgeNow = 1_000;
840
+ const adversarial = createDataSurfaceCommandBridge({
841
+ transport: adversarialLink,
842
+ sessionId: 'session-ack',
843
+ source: 'server-ack',
844
+ peerSource: 'browser-ack',
845
+ authorize: () => true,
846
+ now: () => bridgeNow,
847
+ ttlMs: 1_000,
848
+ timeoutMs: 1_000,
849
+ });
850
+ const adversarialPromise = adversarial.send({
851
+ version: 1,
852
+ commandId: 'adversarial-ack',
853
+ identity,
854
+ expectedRevision: replayed.revision ?? 0,
855
+ controlId: 'set-page',
856
+ payload: { page: 3 },
857
+ });
858
+ await vi.waitFor(() => expect(adversarialLink.messages).toHaveLength(1));
859
+ const adversarialRequest = adversarialLink.messages[0];
860
+ if (adversarialRequest?.type !== 'data-surface.command') {
861
+ throw new Error('Expected a pending command request');
862
+ }
863
+ const adversarialAck = {
864
+ type: 'data-surface.ack',
865
+ version: 1,
866
+ commandId: adversarialRequest.commandId,
867
+ sessionId: adversarialRequest.sessionId,
868
+ source: 'browser-ack',
869
+ expiresAt: adversarialRequest.expiresAt,
870
+ identity: adversarialRequest.identity,
871
+ expectedRevision: adversarialRequest.expectedRevision,
872
+ ok: false,
873
+ reason: 'denied',
874
+ };
875
+ // A wrong session or source is rejected at the transport boundary.
876
+ adversarialLink.receive(adversarialAck, {
877
+ sessionId: 'other-session',
878
+ source: 'browser-ack',
879
+ });
880
+ adversarialLink.receive(adversarialAck, {
881
+ sessionId: 'session-ack',
882
+ source: 'other-browser',
883
+ });
884
+ // Correlated peers cannot smuggle a different source, identity, or
885
+ // expected revision into the pending request.
886
+ adversarialLink.receive({ ...adversarialAck, source: 'other-browser' }, { sessionId: 'session-ack', source: 'browser-ack' });
887
+ adversarialLink.receive({
888
+ ...adversarialAck,
889
+ identity: { ...identity, surfaceId: 'other-surface' },
890
+ }, { sessionId: 'session-ack', source: 'browser-ack' });
891
+ adversarialLink.receive({
892
+ ...adversarialAck,
893
+ identity: {
894
+ ...identity,
895
+ subject: { type: 'tenant', id: 'tenant-b' },
896
+ },
897
+ }, { sessionId: 'session-ack', source: 'browser-ack' });
898
+ adversarialLink.receive({
899
+ ...adversarialAck,
900
+ expectedRevision: adversarialAck.expectedRevision + 1,
901
+ }, { sessionId: 'session-ack', source: 'browser-ack' });
902
+ // Even a fully correlated ACK is refused after its signed envelope TTL.
903
+ bridgeNow = adversarialRequest.expiresAt + 1;
904
+ adversarialLink.receive(adversarialAck, {
905
+ sessionId: 'session-ack',
906
+ source: 'browser-ack',
907
+ });
908
+ await expect(adversarialPromise).resolves.toMatchObject({
909
+ ok: false,
910
+ reason: 'expired',
911
+ });
912
+ // Replay and an ACK for an absent pending command are harmless no-ops.
913
+ adversarialLink.receive(adversarialAck, {
914
+ sessionId: 'session-ack',
915
+ source: 'browser-ack',
916
+ });
917
+ adversarialLink.receive({ ...adversarialAck, commandId: 'never-pending' }, { sessionId: 'session-ack', source: 'browser-ack' });
918
+ adversarial.dispose();
919
+ const link = bridgeTransport();
920
+ const authenticatedPrincipal = Object.freeze({
921
+ sessionId: 'session-1',
922
+ userId: 'surface-user',
923
+ tenantId: 'tenant-a',
924
+ });
925
+ const browser = createDataSurfaceCommandBridge({
926
+ transport: link,
927
+ sessionId: authenticatedPrincipal.sessionId,
928
+ source: 'server-1',
929
+ peerSource: 'browser-1',
930
+ // Authorization comes from the trusted authenticated session. The
931
+ // command subject is only the requested target and cannot establish
932
+ // authority by itself.
933
+ authorize: async (command) => authenticatedPrincipal.userId === 'surface-user' &&
934
+ command.identity.subject?.type === 'tenant' &&
935
+ command.identity.subject.id === authenticatedPrincipal.tenantId,
936
+ now: () => 1_000,
937
+ ttlMs: 1_000,
938
+ timeoutMs: 100,
939
+ });
940
+ const commandPromise = browser.send({
941
+ version: 1,
942
+ commandId: 'sort-1',
943
+ identity,
944
+ expectedRevision: human?.revision ?? 0,
945
+ controlId: 'toggle-sorting',
946
+ payload: { columnId: 'status' },
947
+ });
948
+ await vi.waitFor(() => expect(link.messages).toHaveLength(1));
949
+ const request = link.messages.at(-1);
950
+ expect(request?.type).toBe('data-surface.command');
951
+ const replayPromise = browser.send({
952
+ version: 1,
953
+ commandId: 'sort-1',
954
+ identity,
955
+ expectedRevision: human?.revision ?? 0,
956
+ controlId: 'toggle-sorting',
957
+ payload: { columnId: 'status' },
958
+ });
959
+ await expect(browser.send({
960
+ version: 1,
961
+ commandId: 'sort-1',
962
+ identity,
963
+ expectedRevision: human?.revision ?? 0,
964
+ controlId: 'toggle-sorting',
965
+ payload: { columnId: 'name' },
966
+ })).resolves.toMatchObject({ ok: false, reason: 'idempotency_conflict' });
967
+ const messagesBeforeSpoof = link.messages.length;
968
+ expect(await browser.send({
969
+ version: 1,
970
+ commandId: 'denied',
971
+ identity: { ...identity, subject: { type: 'tenant', id: 'tenant-b' } },
972
+ expectedRevision: human?.revision ?? 0,
973
+ controlId: 'set-page',
974
+ payload: { page: 2 },
975
+ })).toMatchObject({ ok: false, reason: 'denied' });
976
+ expect(link.messages).toHaveLength(messagesBeforeSpoof);
977
+ link.status('disconnected');
978
+ expect(await commandPromise).toMatchObject({
979
+ ok: false,
980
+ reason: 'disconnected',
981
+ });
982
+ await expect(replayPromise).resolves.toMatchObject({
983
+ ok: false,
984
+ reason: 'disconnected',
985
+ });
986
+ browser.dispose();
987
+ const timeoutLink = bridgeTransport();
988
+ const timeoutBridge = createDataSurfaceCommandBridge({
989
+ transport: timeoutLink,
990
+ sessionId: 'session-1',
991
+ source: 'server-1',
992
+ peerSource: 'browser-1',
993
+ authorize: () => true,
994
+ now: () => 1_000,
995
+ ttlMs: 20,
996
+ timeoutMs: 5,
997
+ });
998
+ await expect(timeoutBridge.send({
999
+ version: 1,
1000
+ commandId: 'timeout-command',
1001
+ identity,
1002
+ expectedRevision: replayed.revision ?? 0,
1003
+ controlId: 'set-page',
1004
+ payload: { page: 2 },
1005
+ })).resolves.toMatchObject({ ok: false, reason: 'timeout' });
1006
+ timeoutBridge.dispose();
1007
+ const malformed = createDataSurfaceCommandBridge({
1008
+ transport: bridgeTransport(),
1009
+ sessionId: 'session-1',
1010
+ source: 'server-1',
1011
+ peerSource: 'browser-1',
1012
+ authorize: () => true,
1013
+ ttlMs: 1_000,
1014
+ timeoutMs: 10,
1015
+ });
1016
+ expect(await malformed.send({ version: 1 })).toMatchObject({
1017
+ ok: false,
1018
+ reason: 'invalid_request',
1019
+ });
1020
+ malformed.dispose();
1021
+ });
1022
+ it('requires a fresh opaque confirmation and rechecks tenant/eligibility on apply', async () => {
1023
+ let revision = 1;
1024
+ let authorizationAllowed = true;
1025
+ let eligibilityAllowed = true;
1026
+ let authoritativeSelection = {
1027
+ queryFingerprint: 'records-query-v1',
1028
+ rowIds: [firstId],
1029
+ };
1030
+ const applied = [];
1031
+ const state = new InMemoryDataSurfaceActionStateStore();
1032
+ const context = {
1033
+ principal: {
1034
+ db,
1035
+ principal: {
1036
+ runAsUserId: 'surface-user',
1037
+ tenantId: 'tenant-a',
1038
+ allowedTools: ['records.archive'],
1039
+ },
1040
+ onBehalfOfUserId: 'human-user',
1041
+ permissions: ['records.read', 'records.update'],
1042
+ postgresRls: false,
1043
+ audit: () => undefined,
1044
+ },
1045
+ };
1046
+ const adapter = createDataSurfaceActionAdapter({
1047
+ state,
1048
+ resolveSurface: async (run) => ({
1049
+ descriptor,
1050
+ revision,
1051
+ actions: {
1052
+ archive: {
1053
+ descriptor: descriptor.actions[0],
1054
+ inputSchema: null,
1055
+ validatePayload: () => ({ valid: true }),
1056
+ confirmation: 'required',
1057
+ execution: 'foreground',
1058
+ tool: 'records.archive',
1059
+ operation: {
1060
+ id: 'records:update',
1061
+ collection: 'records',
1062
+ action: 'update',
1063
+ },
1064
+ authorize: async (invocation) => authorizationAllowed &&
1065
+ invocation.run.context.tenantId === 'tenant-a',
1066
+ eligible: async (_invocation, rowId) => rowId === firstId && eligibilityAllowed
1067
+ ? { eligible: true }
1068
+ : { eligible: false, reason: 'no longer eligible' },
1069
+ apply: async (_invocation, rowId) => {
1070
+ const row = await records.get(String(rowId));
1071
+ if (!row)
1072
+ throw new Error('missing');
1073
+ row.status = 'archived';
1074
+ await row.save();
1075
+ applied.push(String(rowId));
1076
+ return undefined;
1077
+ },
1078
+ },
1079
+ },
1080
+ }),
1081
+ resolveSelection: async (invocation, _selection) => ({
1082
+ revision,
1083
+ queryFingerprint: authoritativeSelection.queryFingerprint,
1084
+ rowIds: authoritativeSelection.rowIds,
1085
+ }),
1086
+ });
1087
+ const base = {
1088
+ version: 1,
1089
+ requestId: 'archive-preview',
1090
+ identity,
1091
+ actionId: 'archive',
1092
+ phase: 'preview',
1093
+ selection: { scope: 'explicit-ids', rowIds: [firstId] },
1094
+ expectedRevision: 1,
1095
+ };
1096
+ expect(await adapter.apply({
1097
+ ...base,
1098
+ phase: 'apply',
1099
+ requestId: 'without-preview',
1100
+ idempotencyKey: 'without-preview',
1101
+ }, context)).toMatchObject({ ok: false, reason: 'confirmation_required' });
1102
+ const preview = await adapter.preview(base, context);
1103
+ expect(preview).toMatchObject({
1104
+ ok: true,
1105
+ confirmationToken: expect.any(String),
1106
+ });
1107
+ const token = preview.confirmationToken;
1108
+ expect(token.length).toBeGreaterThan(20);
1109
+ expect(token).not.toContain('tenant-a');
1110
+ expect(token).not.toContain('archive');
1111
+ // The selected rows and their authoritative query can change without a
1112
+ // surface revision changing. Applying the old token must re-resolve that
1113
+ // selection and fail closed before the action callback is reached.
1114
+ const selectionDriftPreview = await adapter.preview({ ...base, requestId: 'selection-drift-preview' }, context);
1115
+ authoritativeSelection = {
1116
+ queryFingerprint: 'records-query-v2',
1117
+ rowIds: [secondId],
1118
+ };
1119
+ const selectionDrift = await adapter.apply({
1120
+ ...base,
1121
+ phase: 'apply',
1122
+ requestId: 'selection-drift',
1123
+ idempotencyKey: 'selection-drift',
1124
+ confirmationToken: selectionDriftPreview.confirmationToken,
1125
+ }, context);
1126
+ expect(selectionDrift).toMatchObject({
1127
+ ok: false,
1128
+ reason: 'stale_preview',
1129
+ });
1130
+ expect(applied).toEqual([]);
1131
+ authoritativeSelection = {
1132
+ queryFingerprint: 'records-query-v1',
1133
+ rowIds: [firstId],
1134
+ };
1135
+ revision = 2;
1136
+ const stale = await adapter.apply({
1137
+ ...base,
1138
+ phase: 'apply',
1139
+ requestId: 'stale',
1140
+ idempotencyKey: 'stale',
1141
+ confirmationToken: token,
1142
+ }, context);
1143
+ expect(stale).toMatchObject({ ok: false, reason: 'stale_preview' });
1144
+ revision = 1;
1145
+ const freshPreview = await adapter.preview({ ...base, requestId: 'fresh-preview' }, context);
1146
+ expect(freshPreview).toMatchObject({
1147
+ ok: true,
1148
+ confirmationToken: expect.any(String),
1149
+ });
1150
+ expect(freshPreview.confirmationToken).not.toBe(token);
1151
+ authorizationAllowed = false;
1152
+ const authorizationFlip = await adapter.apply({
1153
+ ...base,
1154
+ phase: 'apply',
1155
+ requestId: 'authorization-flip',
1156
+ idempotencyKey: 'authorization-flip',
1157
+ confirmationToken: freshPreview.confirmationToken,
1158
+ }, context);
1159
+ expect(authorizationFlip).toMatchObject({ ok: false, reason: 'denied' });
1160
+ expect(applied).toEqual([]);
1161
+ authorizationAllowed = true;
1162
+ const eligibilityPreview = await adapter.preview({ ...base, requestId: 'eligibility-flip-preview' }, context);
1163
+ eligibilityAllowed = false;
1164
+ const eligibilityFlip = await adapter.apply({
1165
+ ...base,
1166
+ phase: 'apply',
1167
+ requestId: 'eligibility-flip',
1168
+ idempotencyKey: 'eligibility-flip',
1169
+ confirmationToken: eligibilityPreview.confirmationToken,
1170
+ }, context);
1171
+ expect(eligibilityFlip).toMatchObject({
1172
+ ok: true,
1173
+ details: { accepted: 0, skipped: 1 },
1174
+ });
1175
+ expect(applied).toEqual([]);
1176
+ eligibilityAllowed = true;
1177
+ const crossTenantPreview = await adapter.preview({ ...base, requestId: 'cross-tenant-preview' }, context);
1178
+ const otherTenant = {
1179
+ ...context,
1180
+ principal: {
1181
+ ...context.principal,
1182
+ principal: { ...context.principal.principal, tenantId: 'tenant-b' },
1183
+ },
1184
+ };
1185
+ await expect(adapter.apply({
1186
+ ...base,
1187
+ phase: 'apply',
1188
+ requestId: 'cross-tenant-apply',
1189
+ idempotencyKey: 'cross-tenant-apply',
1190
+ confirmationToken: crossTenantPreview.confirmationToken,
1191
+ }, otherTenant)).resolves.toMatchObject({ ok: false, reason: 'confirmation_mismatch' });
1192
+ const otherPrincipal = {
1193
+ ...context,
1194
+ principal: {
1195
+ ...context.principal,
1196
+ principal: {
1197
+ ...context.principal.principal,
1198
+ runAsUserId: 'different-user',
1199
+ },
1200
+ },
1201
+ };
1202
+ await expect(adapter.apply({
1203
+ ...base,
1204
+ phase: 'apply',
1205
+ requestId: 'cross-principal-apply',
1206
+ idempotencyKey: 'cross-principal-apply',
1207
+ confirmationToken: crossTenantPreview.confirmationToken,
1208
+ }, otherPrincipal)).resolves.toMatchObject({ ok: false, reason: 'confirmation_mismatch' });
1209
+ const finalPreview = await adapter.preview({ ...base, requestId: 'final-preview' }, context);
1210
+ const applyRequest = {
1211
+ ...base,
1212
+ phase: 'apply',
1213
+ requestId: 'archive-apply',
1214
+ idempotencyKey: 'archive-apply',
1215
+ confirmationToken: finalPreview.confirmationToken,
1216
+ };
1217
+ const appliedResult = await adapter.apply(applyRequest, context);
1218
+ expect(appliedResult).toMatchObject({ ok: true, details: { accepted: 1 } });
1219
+ expect(applied).toEqual([firstId]);
1220
+ expect(await adapter.apply(applyRequest, context)).toEqual(appliedResult);
1221
+ expect(applied).toHaveLength(1);
1222
+ await expect(adapter.apply({
1223
+ ...applyRequest,
1224
+ requestId: 'confirmation-replay',
1225
+ idempotencyKey: 'confirmation-replay',
1226
+ }, context)).resolves.toMatchObject({ ok: false, reason: 'confirmation_replayed' });
1227
+ expect(await adapter.apply({
1228
+ ...applyRequest,
1229
+ requestId: 'forged',
1230
+ idempotencyKey: 'forged',
1231
+ confirmationToken: 'forged-token',
1232
+ }, context)).toMatchObject({ ok: false, reason: 'invalid_or_expired_confirmation' });
1233
+ const otherPreview = await adapter.preview({ ...base, requestId: 'other-tenant' }, otherTenant);
1234
+ expect(otherPreview).toMatchObject({ ok: false, reason: 'denied' });
1235
+ });
1236
+ it('keeps ContentList and report adapters on the same stable identity/version contract', async () => {
1237
+ const contentDescriptor = buildContentListSurfaceDescriptor({
1238
+ surfaceId: 'content-list-conformance',
1239
+ });
1240
+ expect(contentDescriptor).toMatchObject({
1241
+ version: 1,
1242
+ schemaVersion: CONTENT_LIST_SCHEMA_VERSION,
1243
+ rowKey: 'id',
1244
+ identity: { kind: 'table' },
1245
+ });
1246
+ expect(contentDescriptor.columns.find((column) => column.id === 'description')).toBeUndefined();
1247
+ expect(contentDescriptor.actions.find((action) => action.id === 'delete')).toMatchObject({ requiresConfirmation: true });
1248
+ const report = await buildReportAdapterDescriptor(SurfaceConformanceReport, { tenantScope: 'current' });
1249
+ expect(report).toMatchObject({
1250
+ version: 1,
1251
+ identityField: 'id',
1252
+ resourceId: expect.stringContaining('#current'),
1253
+ queryExecution: { modes: expect.arrayContaining(['silent']) },
1254
+ });
1255
+ expect(report.columns.find((column) => column.fieldName === 'secret')).toBeUndefined();
1256
+ // Execute the report adapter against a real tenant-scoped
1257
+ // SmrtReportCollection. This catches adapters that build a safe
1258
+ // descriptor but forget to carry the tenant boundary into row execution.
1259
+ const reportCollection = {
1260
+ // The fixture's real tenant-scoped collection owns the SQL and tenancy
1261
+ // interceptor. This adapter seam only maps its hydrated objects to the
1262
+ // report adapter's JSON row contract; it deliberately ignores the
1263
+ // adapter's already-validated select hint because the fixture schema is
1264
+ // generated without persisted column metadata.
1265
+ list: async (_options) => (await records.list()).map((row) => ({
1266
+ id: String(row.id),
1267
+ name: row.name,
1268
+ })),
1269
+ count: async (_options) => (await records.list()).length,
1270
+ };
1271
+ const reportRows = await withTenant({ tenantId: 'tenant-a' }, () => queryReportMaterializedRows(SurfaceConformanceReport, {
1272
+ version: 1,
1273
+ requestId: 'report-visible',
1274
+ mode: 'rows',
1275
+ projection: ['id', 'name'],
1276
+ page: { kind: 'offset', offset: 0, limit: 10 },
1277
+ }, {
1278
+ db,
1279
+ collection: reportCollection,
1280
+ adapter: { tenantScope: 'current' },
1281
+ execution: 'visible',
1282
+ }));
1283
+ expect(reportRows).toMatchObject({
1284
+ execution: 'visible',
1285
+ rows: expect.arrayContaining([
1286
+ { id: firstId, name: 'Ada' },
1287
+ { id: secondId, name: 'Grace' },
1288
+ ]),
1289
+ total: { kind: 'exact', value: 2 },
1290
+ });
1291
+ expect(JSON.stringify(reportRows)).not.toContain(hiddenId);
1292
+ // Mount the shipped ContentList itself (compact mode owns the DataTable
1293
+ // surface) and drive its generated descriptor through the same registry
1294
+ // command path used by the standalone human table above.
1295
+ const contentRegistry = createDataSurfaceRegistry();
1296
+ const content = buildContentListSurfaceDescriptor({
1297
+ surfaceId: 'content-list-mounted',
1298
+ });
1299
+ const contentRows = [
1300
+ {
1301
+ id: 'content-a',
1302
+ title: 'Ada article',
1303
+ type: 'article',
1304
+ status: 'published',
1305
+ },
1306
+ {
1307
+ id: 'content-b',
1308
+ title: 'Grace article',
1309
+ type: 'article',
1310
+ status: 'draft',
1311
+ },
1312
+ ];
1313
+ const { container: contentContainer } = render(ContentList, {
1314
+ props: {
1315
+ defaultViewMode: 'compact',
1316
+ contents: contentRows,
1317
+ onEdit: vi.fn(),
1318
+ onDelete: vi.fn(),
1319
+ onAdd: vi.fn(),
1320
+ dataSurface: { registry: contentRegistry, descriptor: content },
1321
+ },
1322
+ });
1323
+ await tick();
1324
+ await vi.waitFor(() => expect(contentRegistry.inspect(content.identity)).toBeDefined());
1325
+ const contentHuman = contentRegistry.inspect(content.identity);
1326
+ const contentBrowser = await contentRegistry.execute({
1327
+ version: 1,
1328
+ commandId: 'content-search',
1329
+ identity: content.identity,
1330
+ expectedRevision: contentHuman?.revision ?? 0,
1331
+ controlId: 'set-search',
1332
+ payload: { search: 'Ada' },
1333
+ });
1334
+ expect(contentBrowser).toMatchObject({ ok: true });
1335
+ expect(contentRegistry.inspect(content.identity)?.state).toMatchObject({
1336
+ table: { state: { search: 'Ada' } },
1337
+ });
1338
+ await expectNoA11yViolations(contentContainer);
1339
+ // The report presentation consumes the adapter's production table columns
1340
+ // while retaining the same stable row identity and mounted command state.
1341
+ const reportIdentity = {
1342
+ surfaceId: report.resourceId,
1343
+ kind: 'report',
1344
+ subject: { type: 'tenant', id: 'tenant-a' },
1345
+ };
1346
+ const reportSurface = {
1347
+ version: 1,
1348
+ identity: reportIdentity,
1349
+ schemaVersion: report.schema.version,
1350
+ label: report.reportClassName,
1351
+ rowKey: report.identityField,
1352
+ columns: report.dataTable.columns.map((column) => ({
1353
+ id: column.id,
1354
+ label: column.label,
1355
+ capabilities: [
1356
+ 'read',
1357
+ ...(column.sortable ? ['sort'] : []),
1358
+ ...(column.filterable ? ['filter'] : []),
1359
+ ],
1360
+ })),
1361
+ query: {
1362
+ modes: ['rows', 'count'],
1363
+ projectableColumnIds: report.schema.fields
1364
+ .filter((field) => field.projectable)
1365
+ .map((field) => field.id),
1366
+ sortableColumnIds: report.schema.fields
1367
+ .filter((field) => field.sortable)
1368
+ .map((field) => field.id),
1369
+ },
1370
+ controls: [],
1371
+ actions: [],
1372
+ limits: { maxQueryRows: 50, maxQueryBytes: 20_000, maxSelectionSize: 10 },
1373
+ };
1374
+ const reportRegistry = createDataSurfaceRegistry();
1375
+ const { container: reportContainer } = render(DataTable, {
1376
+ props: {
1377
+ data: (reportRows.rows ?? []),
1378
+ columns: report.dataTable.columns,
1379
+ rowKey: report.dataTable.rowKey,
1380
+ controller: createDataTableController({
1381
+ columnIds: report.dataTable.columns.map((column) => column.id),
1382
+ }),
1383
+ caption: 'Surface Conformance Report',
1384
+ dataSurface: { registry: reportRegistry, descriptor: reportSurface },
1385
+ },
1386
+ });
1387
+ await tick();
1388
+ await vi.waitFor(() => expect(reportRegistry.inspect(reportIdentity)).toBeDefined());
1389
+ expect(reportRegistry.inspect(reportIdentity)?.descriptor.identity).toEqual(reportIdentity);
1390
+ await expectNoA11yViolations(reportContainer);
1391
+ });
1392
+ });