gogcli-mcp 2.21.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +321 -54
- package/dist/lib.js +322 -55
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +147 -0
- package/src/connector-runtime.ts +579 -79
- package/src/google-token.ts +181 -15
- package/src/runner.ts +67 -2
- package/src/tools/utils.ts +70 -5
- package/src/worker.ts +12 -1
- package/tests/auth-log.test.ts +508 -0
- package/tests/connector-runtime.test.ts +675 -2
- package/tests/google-token.test.ts +125 -4
- package/tests/runner.test.ts +21 -1
- package/tests/tools/auth-401-context.test.ts +42 -0
- package/tests/tools/auth-401-shapes.test.ts +50 -0
- package/tests/tools/utils.test.ts +55 -2
|
@@ -129,6 +129,13 @@ describe('makeAccessTokenSource', () => {
|
|
|
129
129
|
);
|
|
130
130
|
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
|
|
131
131
|
await expect(source()).rejects.toThrow(/expired or been revoked|re-?enrol|re-?authoriz/i);
|
|
132
|
+
// The literal token, not just the prose. tools/utils.ts picks the richer
|
|
133
|
+
// INVALID_GRANT_HINT (7-day Testing-mode cause + the headless re-auth pair)
|
|
134
|
+
// by matching `invalid_grant`; a message that only DESCRIBES the failure
|
|
135
|
+
// earns the generic "authentication may have expired" advice instead, which
|
|
136
|
+
// is what this mint used to get while the identical failure reported by gog
|
|
137
|
+
// got the specific guidance.
|
|
138
|
+
await expect(source()).rejects.toThrow(/invalid_grant/);
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
it('does not cache a failure, so a transient outage is not sticky', async () => {
|
|
@@ -277,10 +284,24 @@ describe('makeAccessTokenSource', () => {
|
|
|
277
284
|
});
|
|
278
285
|
|
|
279
286
|
it('does not let two different credentials share one in-flight exchange', async () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
// Answers are keyed off the REQUEST's refresh_token, never off call order.
|
|
288
|
+
//
|
|
289
|
+
// `mockResolvedValueOnce` twice would assign alice's token to whichever
|
|
290
|
+
// exchange reaches fetch first, and that is not the order the sources were
|
|
291
|
+
// called in: each one awaits `crypto.subtle.digest` (via the cache key)
|
|
292
|
+
// before it reaches fetch, and two digest promises are not guaranteed to
|
|
293
|
+
// settle in the order they were started. Measured against this very source,
|
|
294
|
+
// bob's request went first in 4 of 400 races (~1%), which is exactly the
|
|
295
|
+
// rate at which an order-keyed mock hands alice bob's token and reddens CI
|
|
296
|
+
// for a reason that has nothing to do with the behaviour under test.
|
|
297
|
+
//
|
|
298
|
+
// Keying off the body also makes the assertion say what it means: the point
|
|
299
|
+
// is that each credential got ITS OWN token, which is a claim about
|
|
300
|
+
// pairing, not about sequence.
|
|
301
|
+
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
|
302
|
+
const refreshToken = new URLSearchParams(init.body).get('refresh_token');
|
|
303
|
+
return tokenResponse(refreshToken === 'rt-alice' ? 'ya29.alice' : 'ya29.bob');
|
|
304
|
+
});
|
|
284
305
|
vi.stubGlobal('fetch', fetchMock);
|
|
285
306
|
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
286
307
|
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
@@ -302,3 +323,103 @@ describe('makeAccessTokenSource', () => {
|
|
|
302
323
|
);
|
|
303
324
|
});
|
|
304
325
|
});
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A token Google has REJECTED must leave the cache.
|
|
329
|
+
*
|
|
330
|
+
* The cache's only read guard is time-based (`expiresAt - EXPIRY_MARGIN_MS >
|
|
331
|
+
* Date.now()`), so before this existed a token Google answered 401 to was
|
|
332
|
+
* re-served for the rest of its nominal hour — every call in that window failed
|
|
333
|
+
* identically, retrying changed nothing, and only reconnecting (a fresh isolate
|
|
334
|
+
* with an empty cache) helped.
|
|
335
|
+
*
|
|
336
|
+
* The eviction is deliberately NOT `clearAccessTokenCache()`. That is a test
|
|
337
|
+
* seam which drops EVERY credential; using it here would mean one caller's dead
|
|
338
|
+
* token forced a re-mint on every other caller sharing the isolate — a new bug
|
|
339
|
+
* in the same shape as the ones this module was built to avoid.
|
|
340
|
+
*/
|
|
341
|
+
describe('invalidate', () => {
|
|
342
|
+
it('evicts the rejected token so the next call mints a fresh one', async () => {
|
|
343
|
+
const fetchMock = vi
|
|
344
|
+
.fn()
|
|
345
|
+
.mockResolvedValueOnce(tokenResponse('ya29.first'))
|
|
346
|
+
.mockResolvedValueOnce(tokenResponse('ya29.second'));
|
|
347
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
348
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
349
|
+
|
|
350
|
+
expect(await source()).toBe('ya29.first');
|
|
351
|
+
expect(await source()).toBe('ya29.first');
|
|
352
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
353
|
+
|
|
354
|
+
// True: something was actually dropped, so a retry has a chance of using a
|
|
355
|
+
// DIFFERENT token. That answer is what the caller's retry decision hangs on.
|
|
356
|
+
expect(await source.invalidate('ya29.first')).toBe(true);
|
|
357
|
+
|
|
358
|
+
expect(await source()).toBe('ya29.second');
|
|
359
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('evicts only the credential whose token was rejected', async () => {
|
|
363
|
+
// The multi-tenant property. A Worker isolate serves many callers; one
|
|
364
|
+
// caller meeting a dead token must not cost everyone else a re-mint.
|
|
365
|
+
const fetchMock = vi
|
|
366
|
+
.fn()
|
|
367
|
+
.mockResolvedValueOnce(tokenResponse('ya29.alice'))
|
|
368
|
+
.mockResolvedValueOnce(tokenResponse('ya29.bob'));
|
|
369
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
370
|
+
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
371
|
+
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
372
|
+
|
|
373
|
+
expect(await alice()).toBe('ya29.alice');
|
|
374
|
+
expect(await bob()).toBe('ya29.bob');
|
|
375
|
+
expect(await alice.invalidate('ya29.alice')).toBe(true);
|
|
376
|
+
|
|
377
|
+
// Bob's entry is untouched — still served from cache, no third exchange.
|
|
378
|
+
expect(await bob()).toBe('ya29.bob');
|
|
379
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('ignores a rejection naming a token that is no longer the cached one', async () => {
|
|
383
|
+
// The ABA case. Caller A sends token T; while A is in flight, B refreshes
|
|
384
|
+
// the entry to T2; A comes back with "T was rejected". Dropping T2 there
|
|
385
|
+
// would make the next call mint a third token for nothing, and two callers
|
|
386
|
+
// could keep evicting each other's work indefinitely.
|
|
387
|
+
const fetchMock = vi.fn(async () => tokenResponse('ya29.current'));
|
|
388
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
389
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
390
|
+
|
|
391
|
+
expect(await source()).toBe('ya29.current');
|
|
392
|
+
expect(await source.invalidate('ya29.superseded')).toBe(false);
|
|
393
|
+
expect(await source()).toBe('ya29.current');
|
|
394
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('reports nothing evicted when the credential has no cached token at all', async () => {
|
|
398
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
399
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
400
|
+
expect(await source.invalidate('ya29.never-minted')).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('offers no eviction at all for a directly-supplied token, which cannot be re-minted', async () => {
|
|
404
|
+
// The #230 path stores an ACCESS token, so there is nothing to mint from:
|
|
405
|
+
// reading again returns the identical string.
|
|
406
|
+
//
|
|
407
|
+
// The ABSENCE of the method is the signal, not a `false` return. A source
|
|
408
|
+
// that answers false is indistinguishable from a mintable one whose token a
|
|
409
|
+
// concurrent caller already replaced, and the connector logs those two as
|
|
410
|
+
// different causes — so a source that attached an always-false `invalidate`
|
|
411
|
+
// made the auth log blame concurrency for "nothing here can mint a
|
|
412
|
+
// replacement", which is the one case where re-authorizing IS the repair.
|
|
413
|
+
const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
|
|
414
|
+
expect(source.invalidate).toBeUndefined();
|
|
415
|
+
expect(await source()).toBe('ya29.direct');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('offers no eviction for a source that cannot mint at all', async () => {
|
|
419
|
+
// GOG_REFRESH_TOKEN without an OAuth client: the source exists only to fail
|
|
420
|
+
// loudly on first use, so there is never a cached token behind it.
|
|
421
|
+
const source = makeAccessTokenSource({ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' })!;
|
|
422
|
+
expect(source.invalidate).toBeUndefined();
|
|
423
|
+
await expect(source()).rejects.toThrow(/GOG_CLIENT_SECRET/);
|
|
424
|
+
});
|
|
425
|
+
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { spawn as mockedSpawn } from 'node:child_process';
|
|
4
|
-
import { run, runBinary, runExecutor } from '../src/runner.js';
|
|
4
|
+
import { run, runBinary, runExecutor, RunnerTransportError, isRunnerTransportError } from '../src/runner.js';
|
|
5
5
|
import type { Spawner, GogExecutor } from '../src/runner.js';
|
|
6
6
|
|
|
7
7
|
// The real spawn is dynamically imported inside runner's default executor.
|
|
@@ -763,6 +763,26 @@ describe('run executor seam', () => {
|
|
|
763
763
|
}
|
|
764
764
|
});
|
|
765
765
|
|
|
766
|
+
// run() re-wraps every thrown error to redact secrets from its message. That
|
|
767
|
+
// rewrap must not cost a RunnerTransportError its TYPE: the type is the only
|
|
768
|
+
// thing that tells diagnose() the failure was the connector's transport and
|
|
769
|
+
// not the caller's Google credential, and a bare Error puts it straight back
|
|
770
|
+
// to guessing from prose.
|
|
771
|
+
it('preserves a RunnerTransportError through the redacting rewrap', async () => {
|
|
772
|
+
const executor = vi.fn(async () => {
|
|
773
|
+
throw new RunnerTransportError('runner key mismatch; saw 1//0eLEAKED-REFRESH end', 'transport-auth', 401);
|
|
774
|
+
}) as unknown as GogExecutor;
|
|
775
|
+
const err = await runExecutor
|
|
776
|
+
.run({ executor }, () => run(['gmail', 'get', 'm1']))
|
|
777
|
+
.catch((e: unknown) => e);
|
|
778
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
779
|
+
expect((err as RunnerTransportError).kind).toBe('transport-auth');
|
|
780
|
+
expect((err as RunnerTransportError).status).toBe(401);
|
|
781
|
+
// and it is still redacted
|
|
782
|
+
expect((err as Error).message).not.toContain('1//0eLEAKED-REFRESH');
|
|
783
|
+
expect((err as Error).message).toContain('[REDACTED]');
|
|
784
|
+
});
|
|
785
|
+
|
|
766
786
|
it('options.spawner takes precedence over an injected ALS executor', async () => {
|
|
767
787
|
const spawner = makeSpawner(0, '{"via":"spawner"}');
|
|
768
788
|
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diagnose } from '../../src/tools/utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `401` is a status code, but it is also just a number, and gog's output is
|
|
6
|
+
* full of numbers that are row indices, ranges and counts. The pattern used to
|
|
7
|
+
* be a bare `\b401\b`, so "row 401 is outside the sheet grid" — a pure Sheets
|
|
8
|
+
* range error — told the caller to re-authorize a healthy Google account.
|
|
9
|
+
*
|
|
10
|
+
* That is the same defect this PR exists to remove, reached from gog's stderr
|
|
11
|
+
* instead of the runner's status line: a caller is sent to do a manual,
|
|
12
|
+
* account-wide re-auth that cannot possibly fix their problem.
|
|
13
|
+
*
|
|
14
|
+
* So a 401 now has to look like a STATUS, not like an integer.
|
|
15
|
+
*/
|
|
16
|
+
const reauth = async (msg: string) => {
|
|
17
|
+
const r = await diagnose(new Error(msg));
|
|
18
|
+
return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('401 must look like a status, not any integer', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
['row 401 is outside the sheet grid'],
|
|
24
|
+
['wrote 401 rows'],
|
|
25
|
+
['A401:B401 exceeds grid limits'],
|
|
26
|
+
['deleted 401 messages'],
|
|
27
|
+
['sheet has 401 columns'],
|
|
28
|
+
])('does NOT claim an auth failure for: %s', async (msg) => {
|
|
29
|
+
expect(await reauth(msg)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
['googleapi: Error 401: Invalid Credentials, authError'],
|
|
34
|
+
['HTTP 401 Unauthorized'],
|
|
35
|
+
['request failed with status 401'],
|
|
36
|
+
['unexpected status code 401'],
|
|
37
|
+
['401 Unauthorized'],
|
|
38
|
+
['server responded 401: token rejected'],
|
|
39
|
+
])('still DOES claim an auth failure for: %s', async (msg) => {
|
|
40
|
+
expect(await reauth(msg)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diagnose } from '../../src/tools/utils.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #246: making 401 "look like a status" (#245 follow-up) required a separator
|
|
6
|
+
* between the status word and the number. `\s*[:=]?\s*` cannot cross an opening
|
|
7
|
+
* paren or a JSON quote, so the CANONICAL shape gog emits for a Google auth
|
|
8
|
+
* failure stopped matching entirely:
|
|
9
|
+
*
|
|
10
|
+
* Google API error (401 authError): Invalid Credentials
|
|
11
|
+
*
|
|
12
|
+
* That shape is hard-coded in connector-runtime.ts as /Google API error \(401\b/
|
|
13
|
+
* and used as the fixture across auth-log and connector-runtime tests. Losing it
|
|
14
|
+
* is strictly worse than the `row 401` false positive that motivated the change:
|
|
15
|
+
* a false positive sends someone on a pointless re-auth, but this leaves a REAL
|
|
16
|
+
* dead credential with no hint at all.
|
|
17
|
+
*/
|
|
18
|
+
const reauth = async (msg: string) => {
|
|
19
|
+
const r = await diagnose(new Error(msg));
|
|
20
|
+
return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('401 shapes that ARE auth failures', () => {
|
|
24
|
+
it.each([
|
|
25
|
+
['Google API error (401 authError): Invalid Credentials'], // the canonical gog shape
|
|
26
|
+
['Google API error (401): Invalid Credentials'],
|
|
27
|
+
['{"code": 401, "message": "Invalid Credentials"}'], // JSON body
|
|
28
|
+
['{"status":401}'],
|
|
29
|
+
['googleapi: Error 401: Invalid Credentials, authError'],
|
|
30
|
+
['HTTP 401 Unauthorized'],
|
|
31
|
+
['request failed with status 401'],
|
|
32
|
+
['unexpected status code 401'],
|
|
33
|
+
['response=401'],
|
|
34
|
+
])('claims an auth failure for: %s', async (msg) => {
|
|
35
|
+
expect(await reauth(msg)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('401 shapes that are NOT auth failures', () => {
|
|
40
|
+
it.each([
|
|
41
|
+
['row 401 is outside the sheet grid'],
|
|
42
|
+
['wrote 401 rows'],
|
|
43
|
+
['A401:B401 exceeds grid limits'],
|
|
44
|
+
['deleted 401 messages'],
|
|
45
|
+
['sheet has 401 columns'],
|
|
46
|
+
['error: could not write row 401'], // status word present, but far from the number
|
|
47
|
+
])('stays silent for: %s', async (msg) => {
|
|
48
|
+
expect(await reauth(msg)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import * as runner from '../../src/runner.js';
|
|
3
3
|
import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { RunnerTransportError } from '../../src/connector-runtime.js';
|
|
5
|
+
|
|
6
|
+
// PARTIAL mock: only `run` is stubbed. A full automock would also replace
|
|
7
|
+
// `RunnerTransportError` and `isRunnerTransportError`, and diagnose()'s whole
|
|
8
|
+
// point is that it classifies the REAL class structurally — an automocked
|
|
9
|
+
// brand check answers undefined for everything and the tests below would pass
|
|
10
|
+
// while proving nothing.
|
|
11
|
+
vi.mock('../../src/runner.js', async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof runner>()),
|
|
13
|
+
run: vi.fn(),
|
|
14
|
+
}));
|
|
6
15
|
|
|
7
16
|
beforeEach(() => vi.clearAllMocks());
|
|
8
17
|
|
|
@@ -455,3 +464,47 @@ describe('formatAuthHealth', () => {
|
|
|
455
464
|
expect(formatAuthHealth('{"foo":1}', NOW)).toBe('{"foo":1}');
|
|
456
465
|
});
|
|
457
466
|
});
|
|
467
|
+
|
|
468
|
+
// The connector's own transport failing is NOT the Google credential failing.
|
|
469
|
+
// diagnose() must recognise that structurally, from the error's type, because
|
|
470
|
+
// the runner's prose ("unauthorized") is indistinguishable from Google's.
|
|
471
|
+
describe('diagnose: runner-authored transport failures', () => {
|
|
472
|
+
it('blames the runner key, not the Google account, for a transport-auth failure', async () => {
|
|
473
|
+
vi.mocked(runner.run)
|
|
474
|
+
.mockRejectedValueOnce(
|
|
475
|
+
new RunnerTransportError('gog-runner rejected the bearer token; GOG_RUNNER_KEY vs RUNNER_KEY', 'transport-auth', 401),
|
|
476
|
+
)
|
|
477
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
478
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
479
|
+
const text = result.content[0].text as string;
|
|
480
|
+
expect(text).not.toContain('gog_auth_add');
|
|
481
|
+
expect(text).not.toMatch(/re-authorize the account/i);
|
|
482
|
+
expect(text).toContain('GOG_RUNNER_KEY');
|
|
483
|
+
expect(text).toContain('RUNNER_KEY');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('lets the TYPE beat the prose: runner words that read like Google words get no re-auth hint', async () => {
|
|
487
|
+
// The regression this whole change exists to prevent. A runner-authored
|
|
488
|
+
// failure whose text happens to contain every Google auth signal must still
|
|
489
|
+
// not produce Google auth advice — the request never reached Google.
|
|
490
|
+
vi.mocked(runner.run)
|
|
491
|
+
.mockRejectedValueOnce(
|
|
492
|
+
new RunnerTransportError('unauthorized: 401 invalid_grant, token has been expired or revoked', 'transport-request', 400),
|
|
493
|
+
)
|
|
494
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
495
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
496
|
+
const text = result.content[0].text as string;
|
|
497
|
+
expect(text).not.toContain('gog_auth_add');
|
|
498
|
+
expect(text).not.toContain('often transient');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('advises a retry for a retryable transport failure', async () => {
|
|
502
|
+
vi.mocked(runner.run)
|
|
503
|
+
.mockRejectedValueOnce(new RunnerTransportError('gog-runner is restarting; retry this call.', 'transport-retryable', 503))
|
|
504
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
505
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
506
|
+
const text = result.content[0].text as string;
|
|
507
|
+
expect(text).toContain('often transient');
|
|
508
|
+
expect(text).not.toContain('gog_auth_add');
|
|
509
|
+
});
|
|
510
|
+
});
|