-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathaction-record-load-denied.test.ts
More file actions
511 lines (451 loc) · 23.3 KB
/
Copy pathaction-record-load-denied.test.ts
File metadata and controls
511 lines (451 loc) · 23.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#14143] A handler must be able to tell "the caller cannot read this row"
* from "this action legitimately has no record".
*
* ## The defect
*
* Both action doors load the subject row in the CALLER's own scope, swallow the
* failure, and then stamp `record.id = recordId` under the condition
* `record && record.id == null && recordId`. A failed load leaves `record` as
* `{}` — so `record.id` is exactly `null`, and the stamp condition and the
* load-failure condition COINCIDE. The body that follows runs ELEVATED
* (`isSystem: true`, settled design — #3914), so authorization has to be
* re-established inside the handler, and the predicate an author reaches for
* first —
*
* if (!ctx.record?.id) return refuse();
*
* — was therefore ALWAYS false, including on a row the caller cannot read.
*
* ⚠️ The stamp is NOT the defect and is deliberately kept: a new-record /
* record-less action legitimately depends on `recordId` being in place. That is
* the regression these tests pin alongside the fix — every "denied" case below
* asserts `ctx.record.id` is STILL there.
*
* ## What is pinned
*
* 1. **The predicate is real, on BOTH doors.** REST `/actions` and the MCP
* `run_action` bridge each emit `recordLoadDenied: true` when the
* caller-scope load did not deliver the row. A signal only one door sets
* would be an authorization guard silently inert on the other — the same
* defect, one door over.
* 2. **The stamp survives.** `ctx.record.id` is present in every denied case,
* and an object-less action invoked with a `recordId` still gets it.
* 3. **The flag is ABSENT, not `false`, when nothing was refused** — the
* `referentialFieldClear` marker convention on this seam. Every such
* absence assertion has a FIRING POSITIVE CONTROL in the same file, on the
* same rig: the identical expectation shape reports `true` for the
* unauthorized caller, so an absence here cannot be a rig that never
* populates the key.
* 4. **The body face carries it.** An inline `body` is the surface an AI
* author writes most, and its sandbox `ctx` is a FIXED key set — a key the
* dispatcher sets but the sandbox never marshals would read as `undefined`
* inside every body, re-manufacturing the always-false guard. Pinned by
* running a real QuickJS body.
* 5. **[#15168] The FLOW face carries it too, on both doors.** #14244 declared
* `AutomationContext.recordLoadDenied` and nothing populated it: a
* `type: 'flow'` action reached `automation.execute` with the stamped
* `record.id` and no verdict, so a `runAs: 'system'` flow guarding on the
* declared key was inert — never `true`, never wrong, and indistinguishable
* from a flow whose caller could read the row. The last describe blocks
* read the context the automation service is actually handed, through BOTH
* doors, and pin that the two agree.
*
* ## The RLS double is faithful on the one point that matters
*
* `find` here honours `options.context.userId`: the row exists and is returned
* to its owner, and is INVISIBLE to anyone else — which is exactly how row-level
* security manifests to `callData('get', …)`, and why the real
* `recordNotFoundError` (404 `RECORD_NOT_FOUND`) is what the dispatcher then
* catches. The tests below use the REAL `callData`, so nothing about the
* refused/absent collapse is mocked away: an unseen row and a nonexistent id
* reach the catch as the same error, which is precisely why the fix is a
* separate channel rather than an inspection of the caught error.
*/
import { describe, it, expect, vi } from 'vitest';
import { HttpDispatcher } from './http-dispatcher.js';
import {
callData,
invokeBusinessAction,
loadActionSubjectRecord,
actionRecordLoadSignal,
GLOBAL_ACTION_OBJECT_KEY,
} from './action-execution.js';
import { actionBodyRunnerFactory } from './sandbox/body-runner.js';
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
const OWNER = 'usr_owner';
const STRANGER = 'usr_stranger';
const RECORD_ID = 'case_1';
const ACTION = {
name: 'close_case',
label: 'Close',
objectName: 'crm_case',
type: 'script',
target: 'close_case',
ai: { exposed: true, description: 'Close a case.' },
};
/**
* [#15168] The same subject row, reached through a `type: 'flow'` action — the
* FLOW face of the signal. Declared on the same object as the script action so
* both faces run against the ONE row-scoped engine double below: a rig that
* gave the flow tests their own, more permissive read path could report a
* populated key that production never populates.
*/
const FLOW_NAME = 'crm_case_escalate_wizard';
const FLOW_ACTION = {
name: 'escalate_case',
label: 'Escalate',
objectName: 'crm_case',
type: 'flow',
target: FLOW_NAME,
ai: { exposed: true, description: 'Escalate a case.' },
};
const OBJECT_DEF = { name: 'crm_case', actions: [ACTION, FLOW_ACTION] };
/** The acting principal, as `resolveExecutionContext` builds one. */
function ec(userId: string) {
return { userId, tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] };
}
/**
* An engine whose reads are ROW-SCOPED: `crm_case:case_1` is visible to its
* owner and to nobody else. This is the whole point of the double — a stub that
* returned the row to everyone would pass while the defect was live.
*/
function makeQl() {
const executeAction = vi.fn(async (_object: string, _action: string, _ctx: any) => ({ ok: true }));
const schemaOf = (n: string) => (n === OBJECT_DEF.name ? OBJECT_DEF : undefined);
const ql: any = {
executeAction,
getSchema: schemaOf,
registry: { getObject: schemaOf, getItem: () => undefined },
find: vi.fn(async (object: string, options?: any) => {
if (object !== OBJECT_DEF.name) return [];
const caller = options?.context?.userId;
return caller === OWNER
? [{ id: RECORD_ID, status: 'open', owner_id: OWNER }]
: [];
}),
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
};
return ql;
}
/** REST — `POST /actions/crm_case/close_case/case_1`. Returns the handler ctx. */
async function dispatchRest(userId: string, ql: any, path = `/crm_case/close_case/${RECORD_ID}`) {
const metadata: any = {
load: vi.fn(async () => null),
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
listObjects: vi.fn(async () => [OBJECT_DEF]),
getObject: vi.fn(async (n: string) => (n === OBJECT_DEF.name ? OBJECT_DEF : undefined)),
};
const kernel: any = {
context: {
getService: (n: string) =>
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null,
},
};
const context: any = { request: {}, environmentId: 'platform', executionContext: ec(userId) };
const res: any = await (new HttpDispatcher(kernel) as any).handleActions(path, 'POST', {}, context);
return { response: res.response, actionCtx: ql.executeAction.mock.calls[0]?.[2] };
}
/**
* MCP — `run_action`. Wired to the REAL `callData`, so the row-scoped read and
* its 404 are the ones production runs, not a hand-thrown stand-in.
*/
async function dispatchMcp(userId: string, ql: any, input: Record<string, unknown> = { recordId: RECORD_ID }) {
const deps: any = { resolveService: async () => undefined, getObjectQL: async () => ql };
const requestContext: any = { request: {}, environmentId: 'platform' };
const caller = ec(userId);
await invokeBusinessAction(deps, requestContext, ACTION.name, input as any, {
driver: undefined,
envId: 'platform',
ec: caller,
getMeta: () => ({ listObjects: async () => [OBJECT_DEF] }),
callData: (action, params, dataDriver, scopeId, execCtx) =>
callData(deps, requestContext, action, params, dataDriver, scopeId, execCtx),
});
return { actionCtx: ql.executeAction.mock.calls[0]?.[2] };
}
describe('#14143 — REST /actions tells a handler its caller-scope load was refused', () => {
it('a caller who CANNOT read the row reaches the handler with recordLoadDenied === true', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchRest(STRANGER, ql);
expect(actionCtx).toBeDefined();
expect(actionCtx.recordLoadDenied).toBe(true);
// ⛔ The stamp is NOT removed — a record-less action depends on it, and
// this is the coincidence that made the natural guard useless: the id
// is here whether or not the caller can see the row, which is why the
// flag above (and not `record.id`) is the authorization predicate.
expect(actionCtx.record.id).toBe(RECORD_ID);
expect(Boolean(actionCtx.record?.id)).toBe(true);
// …and nothing of the row itself leaked to a caller who cannot read it.
expect(actionCtx.record.status).toBeUndefined();
expect(actionCtx.record.owner_id).toBeUndefined();
});
it('the row OWNER reaches the handler with the real row and no flag at all', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchRest(OWNER, ql);
expect(actionCtx.record).toMatchObject({ id: RECORD_ID, status: 'open', owner_id: OWNER });
// ABSENT, not `false` — read as `ctx.recordLoadDenied === true`. The
// firing control for this zero is the case above: same rig, same
// expectation shape, and it reports `true`.
expect('recordLoadDenied' in actionCtx).toBe(false);
expect(actionCtx.recordLoadDenied).toBeUndefined();
});
it('a new-record action (no recordId) is untouched — no load, no flag', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchRest(STRANGER, ql, '/crm_case/close_case');
expect(actionCtx.record).toEqual({});
expect('recordLoadDenied' in actionCtx).toBe(false);
// No caller-scope read was even attempted for the subject row.
expect(ql.find.mock.calls.filter((c: any[]) => c[0] === OBJECT_DEF.name)).toHaveLength(0);
});
});
describe('#14143 — MCP run_action emits the SAME signal as the REST door', () => {
it('a caller who CANNOT read the row reaches the handler with recordLoadDenied === true', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchMcp(STRANGER, ql);
expect(actionCtx.recordLoadDenied).toBe(true);
expect(actionCtx.record.id).toBe(RECORD_ID); // stamp preserved
expect(actionCtx.record.status).toBeUndefined();
});
it('the row OWNER reaches the handler with the real row and no flag at all', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchMcp(OWNER, ql);
expect(actionCtx.record).toMatchObject({ id: RECORD_ID, status: 'open' });
expect('recordLoadDenied' in actionCtx).toBe(false);
});
it('a record-less invocation (no recordId) is untouched — no load, no flag', async () => {
const ql = makeQl();
const { actionCtx } = await dispatchMcp(STRANGER, ql, {});
expect(actionCtx.record).toEqual({});
expect('recordLoadDenied' in actionCtx).toBe(false);
});
});
describe('#14143 — loadActionSubjectRecord, the ONE producer both doors call', () => {
it('object-less action with a recordId: no load is attempted, and the stamp STILL lands', async () => {
const getRecord = vi.fn(async () => ({ record: { id: 'other' } }));
const out = await loadActionSubjectRecord(GLOBAL_ACTION_OBJECT_KEY, RECORD_ID, getRecord);
expect(getRecord).not.toHaveBeenCalled();
// ⛔ The prohibition this test exists for: a record-less action still
// gets its `recordId`.
expect(out.record).toEqual({ id: RECORD_ID });
expect(out.recordLoadDenied).toBe(false);
expect(actionRecordLoadSignal(out)).toEqual({});
});
it('a thrown load is denied, and the stamp still lands', async () => {
const out = await loadActionSubjectRecord('crm_case', RECORD_ID, async () => {
throw Object.assign(new Error('Record case_1 not found in crm_case'), {
code: 'RECORD_NOT_FOUND', status: 404,
});
});
expect(out.recordLoadDenied).toBe(true);
expect(out.record).toEqual({ id: RECORD_ID });
expect(actionRecordLoadSignal(out)).toEqual({ recordLoadDenied: true });
});
it('a RESOLVED load carrying no row is denied too — declining to throw is not a successful load', async () => {
const out = await loadActionSubjectRecord('crm_case', RECORD_ID, async () => ({ record: undefined }));
expect(out.recordLoadDenied).toBe(true);
expect(out.record).toEqual({ id: RECORD_ID });
});
it('a delivered row is not denied and is passed through untouched', async () => {
const row = { id: RECORD_ID, status: 'open' };
const out = await loadActionSubjectRecord('crm_case', RECORD_ID, async () => ({ record: row }));
expect(out.recordLoadDenied).toBe(false);
expect(out.record).toEqual(row);
expect(actionRecordLoadSignal(out)).toEqual({});
});
it('no recordId at all: no load, no flag, no stamp', async () => {
const getRecord = vi.fn(async () => ({ record: { id: 'x' } }));
const out = await loadActionSubjectRecord('crm_case', undefined, getRecord);
expect(getRecord).not.toHaveBeenCalled();
expect(out.record).toEqual({});
expect(out.recordLoadDenied).toBe(false);
});
});
describe('#14143 — the signal crosses into a sandboxed action body', () => {
const runner = new QuickJSScriptRunner();
const SOURCE =
'return { denied: ctx.recordLoadDenied === true, ' +
'guard: !(ctx.record && ctx.record.id), id: ctx.record && ctx.record.id };';
function bodyFn() {
const factory = actionBodyRunnerFactory(runner, { ql: makeQl(), appId: 'crm' });
return factory({
name: ACTION.name,
object: OBJECT_DEF.name,
type: 'script',
body: { language: 'js', source: SOURCE, capabilities: [] },
} as any);
}
it('a body sees recordLoadDenied === true — while the OLD guard is still false', async () => {
const out: any = await bodyFn()!({
record: { id: RECORD_ID },
recordLoadDenied: true,
params: {},
});
expect(out.denied).toBe(true);
// The pre-fix predicate, measured inside the VM: still false, because
// the stamp is still there. That is why a body needs the new key.
expect(out.guard).toBe(false);
expect(out.id).toBe(RECORD_ID);
});
it('a body sees NOTHING when the load was fine — firing control for the zero above', async () => {
const out: any = await bodyFn()!({
record: { id: RECORD_ID, status: 'open' },
params: {},
});
expect(out.denied).toBe(false);
expect(out.id).toBe(RECORD_ID);
});
});
/**
* [#15168] An automation service double that RECORDS the context it is handed.
* `getFlow` resolves the one held flow so the #9378 not-found row (404) never
* fires; `execute` succeeds, because what is under test is the context that
* reaches the engine, not the engine's verdict.
*/
function makeAutomation() {
const execute = vi.fn(async (_flow: string, _context?: any) => ({ success: true, output: {} }));
const getFlow = vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null));
return { execute, getFlow };
}
/** The context `automation.execute` was handed on its first (only) call. */
function flowContextOf(automation: { execute: any }) {
return automation.execute.mock.calls[0]?.[1];
}
/** REST — `POST /actions/crm_case/escalate_case/case_1` with a flow action. */
async function dispatchRestFlow(userId: string, ql: any, path = `/crm_case/escalate_case/${RECORD_ID}`) {
const automation = makeAutomation();
const metadata: any = {
load: vi.fn(async () => null),
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
listObjects: vi.fn(async () => [OBJECT_DEF]),
getObject: vi.fn(async (n: string) => (n === OBJECT_DEF.name ? OBJECT_DEF : undefined)),
};
const resolve = (n: string) =>
n === 'objectql' || n === 'data' ? ql
: n === 'metadata' ? metadata
: n === 'automation' ? automation
: null;
const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } };
const context: any = { request: {}, environmentId: 'platform', executionContext: ec(userId) };
const res: any = await (new HttpDispatcher(kernel) as any).handleActions(path, 'POST', {}, context);
return { response: res.response, automation, flowCtx: flowContextOf(automation) };
}
/** MCP — `run_action` on the same flow action, through the REAL `callData`. */
async function dispatchMcpFlow(userId: string, ql: any, input: Record<string, unknown> = { recordId: RECORD_ID }) {
const automation = makeAutomation();
const deps: any = {
resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined),
getObjectQL: async () => ql,
};
const requestContext: any = { request: {}, environmentId: 'platform' };
const caller = ec(userId);
await invokeBusinessAction(deps, requestContext, FLOW_ACTION.name, input as any, {
driver: undefined,
envId: 'platform',
ec: caller,
getMeta: () => ({ listObjects: async () => [OBJECT_DEF] }),
callData: (action, params, dataDriver, scopeId, execCtx) =>
callData(deps, requestContext, action, params, dataDriver, scopeId, execCtx),
});
return { automation, flowCtx: flowContextOf(automation) };
}
describe('[#15168] the FLOW face receives the signal — REST /actions', () => {
it('a caller who CANNOT read the row starts the flow with ctx.recordLoadDenied === true', async () => {
const ql = makeQl();
const { automation, flowCtx } = await dispatchRestFlow(STRANGER, ql);
expect(automation.execute).toHaveBeenCalledTimes(1);
expect(automation.execute.mock.calls[0][0]).toBe(FLOW_NAME);
expect(flowCtx).toBeDefined();
// The contract's own predicate, verbatim (`AutomationContext`): a flow
// reads `recordLoadDenied === true`, never a truthiness of `false`.
expect(flowCtx.recordLoadDenied).toBe(true);
// ⛔ Sibling of `record`, never a key ON it — a flow node reading
// `{{record.recordLoadDenied}}` must find nothing, or the signal would
// arrive as a phantom field of the subject row.
expect('recordLoadDenied' in flowCtx.record).toBe(false);
// The stamp survives here exactly as it does on the handler face — it
// is why `record.id` cannot be the authorization predicate.
expect(flowCtx.record.id).toBe(RECORD_ID);
expect(flowCtx.record.status).toBeUndefined();
expect(flowCtx.record.owner_id).toBeUndefined();
// The rest of the envelope is untouched by this card.
expect(flowCtx.object).toBe(OBJECT_DEF.name);
expect(flowCtx.userId).toBe(STRANGER);
});
it('the row OWNER starts the flow with the key ABSENT — not `false`', async () => {
const ql = makeQl();
const { flowCtx } = await dispatchRestFlow(OWNER, ql);
expect(flowCtx.record).toMatchObject({ id: RECORD_ID, status: 'open', owner_id: OWNER });
// The assertion that catches the most likely wrong implementation —
// spreading `{ recordLoadDenied: false }`. Its firing positive control
// is the case above: same rig, same expectation shape, reports `true`.
expect('recordLoadDenied' in flowCtx).toBe(false);
expect(flowCtx.recordLoadDenied).toBeUndefined();
});
it('a record-less flow invocation attempts no load and carries no flag', async () => {
const ql = makeQl();
const { flowCtx } = await dispatchRestFlow(STRANGER, ql, '/crm_case/escalate_case');
expect(flowCtx.record).toEqual({});
expect('recordLoadDenied' in flowCtx).toBe(false);
expect(ql.find.mock.calls.filter((c: any[]) => c[0] === OBJECT_DEF.name)).toHaveLength(0);
});
});
describe('[#15168] the FLOW face receives the signal — MCP run_action', () => {
it('a caller who CANNOT read the row starts the flow with ctx.recordLoadDenied === true', async () => {
const ql = makeQl();
const { automation, flowCtx } = await dispatchMcpFlow(STRANGER, ql);
expect(automation.execute).toHaveBeenCalledTimes(1);
expect(flowCtx.recordLoadDenied).toBe(true);
expect('recordLoadDenied' in flowCtx.record).toBe(false);
expect(flowCtx.record.id).toBe(RECORD_ID); // stamp preserved
expect(flowCtx.record.status).toBeUndefined();
});
it('the row OWNER starts the flow with the key ABSENT — not `false`', async () => {
const ql = makeQl();
const { flowCtx } = await dispatchMcpFlow(OWNER, ql);
expect(flowCtx.record).toMatchObject({ id: RECORD_ID, status: 'open' });
expect('recordLoadDenied' in flowCtx).toBe(false);
expect(flowCtx.recordLoadDenied).toBeUndefined();
});
it('a record-less flow invocation attempts no load and carries no flag', async () => {
const ql = makeQl();
const { flowCtx } = await dispatchMcpFlow(STRANGER, ql, {});
expect(flowCtx.record).toEqual({});
expect('recordLoadDenied' in flowCtx).toBe(false);
});
});
/**
* [#15168] The convergence itself. A per-door assertion is satisfied by two
* copies of a rule, and two copies drifting apart is the defect #14143 was
* filed for and the reason this card had to move both doors in one stroke — so
* the SAME caller against the SAME row is driven through both doors and the
* signal is compared as a set.
*/
describe('[#15168] the two flow doors agree — the same caller, the same row, the same signal', () => {
it('both doors deny for the stranger and both stay silent for the owner', async () => {
const deniedRest = (await dispatchRestFlow(STRANGER, makeQl())).flowCtx;
const deniedMcp = (await dispatchMcpFlow(STRANGER, makeQl())).flowCtx;
const okRest = (await dispatchRestFlow(OWNER, makeQl())).flowCtx;
const okMcp = (await dispatchMcpFlow(OWNER, makeQl())).flowCtx;
// Read as a SET: a collapse to one answer on both doors reddens here
// whatever that one answer is.
expect([
deniedRest.recordLoadDenied,
deniedMcp.recordLoadDenied,
okRest.recordLoadDenied,
okMcp.recordLoadDenied,
]).toEqual([true, true, undefined, undefined]);
expect([
'recordLoadDenied' in deniedRest,
'recordLoadDenied' in deniedMcp,
'recordLoadDenied' in okRest,
'recordLoadDenied' in okMcp,
]).toEqual([true, true, false, false]);
// And the stamp is present on all four, which is what makes the flag —
// not `record.id` — the only usable predicate on either door.
expect([
deniedRest.record.id, deniedMcp.record.id, okRest.record.id, okMcp.record.id,
]).toEqual([RECORD_ID, RECORD_ID, RECORD_ID, RECORD_ID]);
});
});