@openparachute/hub 0.5.7 → 0.5.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { seedInitialAdminIfNeeded } from "../commands/serve.ts";
6
+ import { openHubDb } from "../hub-db.ts";
7
+ import { userCount } from "../users.ts";
8
+
9
+ describe("seedInitialAdminIfNeeded", () => {
10
+ let dir: string;
11
+ let dbPath: string;
12
+
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), "parachute-serve-"));
15
+ dbPath = join(dir, "hub.db");
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ test("returns 'needs-setup' on fresh state with no env vars", async () => {
23
+ const db = openHubDb(dbPath);
24
+ const result = await seedInitialAdminIfNeeded(db, {}, () => {});
25
+ expect(result).toBe("needs-setup");
26
+ expect(userCount(db)).toBe(0);
27
+ });
28
+
29
+ test("seeds an admin from PARACHUTE_INITIAL_ADMIN_* on fresh state", async () => {
30
+ const db = openHubDb(dbPath);
31
+ const log = mock<(line: string) => void>(() => {});
32
+ const result = await seedInitialAdminIfNeeded(
33
+ db,
34
+ {
35
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
36
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "correct horse battery staple",
37
+ },
38
+ log,
39
+ );
40
+ expect(result).toBe("seeded");
41
+ expect(userCount(db)).toBe(1);
42
+ // The log line carries the username so operators can grep container
43
+ // logs to verify the seed fired.
44
+ expect(log).toHaveBeenCalledTimes(1);
45
+ expect(log.mock.calls[0]?.[0] ?? "").toContain("seeded initial admin");
46
+ expect(log.mock.calls[0]?.[0] ?? "").toContain("ops");
47
+ });
48
+
49
+ test("returns 'exists' when an admin already exists, even with env vars set", async () => {
50
+ // Seed once.
51
+ const db = openHubDb(dbPath);
52
+ await seedInitialAdminIfNeeded(
53
+ db,
54
+ {
55
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
56
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "first-pw",
57
+ },
58
+ () => {},
59
+ );
60
+
61
+ // Same env on a second boot must NOT clobber the existing admin —
62
+ // the seed is first-boot only. (Container restart with the env still
63
+ // set from the Render dashboard is the canonical second-boot.)
64
+ const result = await seedInitialAdminIfNeeded(
65
+ db,
66
+ {
67
+ PARACHUTE_INITIAL_ADMIN_USERNAME: "ops",
68
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "different-pw",
69
+ },
70
+ () => {},
71
+ );
72
+ expect(result).toBe("exists");
73
+ expect(userCount(db)).toBe(1);
74
+ });
75
+
76
+ test("treats whitespace-only username as missing (needs-setup)", async () => {
77
+ const db = openHubDb(dbPath);
78
+ const result = await seedInitialAdminIfNeeded(
79
+ db,
80
+ {
81
+ PARACHUTE_INITIAL_ADMIN_USERNAME: " ",
82
+ PARACHUTE_INITIAL_ADMIN_PASSWORD: "pw",
83
+ },
84
+ () => {},
85
+ );
86
+ expect(result).toBe("needs-setup");
87
+ expect(userCount(db)).toBe(0);
88
+ });
89
+
90
+ test("requires both username and password — half-set env is needs-setup", async () => {
91
+ const db = openHubDb(dbPath);
92
+ const result = await seedInitialAdminIfNeeded(
93
+ db,
94
+ { PARACHUTE_INITIAL_ADMIN_USERNAME: "ops" },
95
+ () => {},
96
+ );
97
+ expect(result).toBe("needs-setup");
98
+ expect(userCount(db)).toBe(0);
99
+ });
100
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Pre-admin setup gate (hub#258). When the hub boots with no admin row
3
+ * (the fresh container case), admin-onboarding-coupled surfaces 503
4
+ * with `{error: "setup_required", setup_url: "/admin/setup"}` so
5
+ * callers can branch on the shape rather than scrape an HTML page.
6
+ *
7
+ * Gated routes (require an admin to be useful): `/login`, `/logout`,
8
+ * `/admin/*` (except `/admin/setup`), `/api/*`.
9
+ *
10
+ * Routes that pass through (platform health, public discovery, OAuth
11
+ * third-party flows, content proxies, the setup page itself):
12
+ *
13
+ * /health, /, /hub.html, /.well-known/*, /admin/setup,
14
+ * /oauth/*, /vault/*, /<service>/*
15
+ *
16
+ * Once an admin row exists, the gate is a no-op — the rest of dispatch
17
+ * runs as normal. The `/admin/setup` route 301s to /login at that
18
+ * point so a stale bookmark still lands somewhere.
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
26
+ import { hubFetch } from "../hub-server.ts";
27
+ import { writeManifest } from "../services-manifest.ts";
28
+ import { createUser } from "../users.ts";
29
+
30
+ interface Harness {
31
+ dir: string;
32
+ cleanup: () => void;
33
+ }
34
+
35
+ function makeHarness(): Harness {
36
+ const dir = mkdtempSync(join(tmpdir(), "setup-gate-"));
37
+ // Minimal hub.html so the `/` route resolves without a 404.
38
+ writeFileSync(join(dir, "hub.html"), "<html>discovery</html>");
39
+ writeManifest({ services: [] }, join(dir, "services.json"));
40
+ return {
41
+ dir,
42
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
43
+ };
44
+ }
45
+
46
+ function req(path: string, init: RequestInit = {}): Request {
47
+ return new Request(`http://127.0.0.1:1939${path}`, init);
48
+ }
49
+
50
+ describe("setup gate (no admin yet)", () => {
51
+ let h: Harness;
52
+ beforeEach(() => {
53
+ h = makeHarness();
54
+ });
55
+ afterEach(() => h.cleanup());
56
+
57
+ test("503s gated admin/api routes with setup_required body", async () => {
58
+ const db = openHubDb(hubDbPath(h.dir));
59
+ try {
60
+ // `/api/me` is a representative gated route — admin SPA bootstrap
61
+ // can't function without an operator identity behind it.
62
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/api/me"));
63
+ expect(res.status).toBe(503);
64
+ const body = (await res.json()) as Record<string, unknown>;
65
+ expect(body.error).toBe("setup_required");
66
+ expect(body.setup_url).toBe("/admin/setup");
67
+ expect(typeof body.error_description).toBe("string");
68
+ } finally {
69
+ db.close();
70
+ }
71
+ });
72
+
73
+ test("503s /login when no admin exists", async () => {
74
+ const db = openHubDb(hubDbPath(h.dir));
75
+ try {
76
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/login"));
77
+ expect(res.status).toBe(503);
78
+ const body = (await res.json()) as Record<string, unknown>;
79
+ expect(body.error).toBe("setup_required");
80
+ } finally {
81
+ db.close();
82
+ }
83
+ });
84
+
85
+ test("/oauth/register passes through the gate (third-party DCR doesn't need admin)", async () => {
86
+ const db = openHubDb(hubDbPath(h.dir));
87
+ try {
88
+ const res = await hubFetch(h.dir, {
89
+ getDb: () => db,
90
+ issuer: "https://hub.example",
91
+ })(
92
+ req("/oauth/register", {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
96
+ }),
97
+ );
98
+ // Either 201 (registered) or 4xx (validation rejection) — the
99
+ // point is NOT 503-setup_required. OAuth surfaces operate
100
+ // independently of the admin-onboarding state.
101
+ expect(res.status).not.toBe(503);
102
+ } finally {
103
+ db.close();
104
+ }
105
+ });
106
+
107
+ test("/health passes through the gate", async () => {
108
+ const db = openHubDb(hubDbPath(h.dir));
109
+ try {
110
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/health"));
111
+ expect(res.status).toBe(200);
112
+ const body = (await res.json()) as Record<string, unknown>;
113
+ expect(body.status).toBe("ok");
114
+ } finally {
115
+ db.close();
116
+ }
117
+ });
118
+
119
+ test("/.well-known/jwks.json passes through the gate", async () => {
120
+ const db = openHubDb(hubDbPath(h.dir));
121
+ try {
122
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/.well-known/jwks.json"));
123
+ // Empty keys array is the no-rotation-yet shape, but the route is
124
+ // reachable — not gated to 503.
125
+ expect(res.status).toBe(200);
126
+ const body = (await res.json()) as { keys: unknown[] };
127
+ expect(Array.isArray(body.keys)).toBe(true);
128
+ } finally {
129
+ db.close();
130
+ }
131
+ });
132
+
133
+ test("/ passes through the gate (renders discovery page)", async () => {
134
+ const db = openHubDb(hubDbPath(h.dir));
135
+ try {
136
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
137
+ expect(res.status).toBe(200);
138
+ expect(res.headers.get("content-type")).toContain("text/html");
139
+ } finally {
140
+ db.close();
141
+ }
142
+ });
143
+
144
+ test("/admin/setup renders the placeholder HTML", async () => {
145
+ const db = openHubDb(hubDbPath(h.dir));
146
+ try {
147
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
148
+ expect(res.status).toBe(200);
149
+ expect(res.headers.get("content-type")).toContain("text/html");
150
+ const html = await res.text();
151
+ // Spot-check the env-var seed is documented — it's the canonical
152
+ // bootstrap path for containers and we don't want a future
153
+ // refactor to silently strip it.
154
+ expect(html).toContain("PARACHUTE_INITIAL_ADMIN_USERNAME");
155
+ expect(html).toContain("PARACHUTE_INITIAL_ADMIN_PASSWORD");
156
+ } finally {
157
+ db.close();
158
+ }
159
+ });
160
+ });
161
+
162
+ describe("setup gate (admin exists)", () => {
163
+ let h: Harness;
164
+ beforeEach(() => {
165
+ h = makeHarness();
166
+ });
167
+ afterEach(() => h.cleanup());
168
+
169
+ test("no-ops once an admin row exists — operator routes resume normal dispatch", async () => {
170
+ const db = openHubDb(hubDbPath(h.dir));
171
+ try {
172
+ await createUser(db, "owner", "pw");
173
+ // /oauth/token rejects GET with 405 in normal dispatch (it's a
174
+ // POST-only endpoint). If the gate were still firing this would
175
+ // come back 503; the 405 confirms regular dispatch resumed.
176
+ const res = await hubFetch(h.dir, { getDb: () => db })(
177
+ req("/oauth/token", { method: "GET" }),
178
+ );
179
+ expect(res.status).toBe(405);
180
+ } finally {
181
+ db.close();
182
+ }
183
+ });
184
+
185
+ test("/admin/setup 301s to /login once an admin exists", async () => {
186
+ const db = openHubDb(hubDbPath(h.dir));
187
+ try {
188
+ await createUser(db, "owner", "pw");
189
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/admin/setup"));
190
+ expect(res.status).toBe(301);
191
+ expect(res.headers.get("location")).toBe("/login");
192
+ } finally {
193
+ db.close();
194
+ }
195
+ });
196
+ });
@@ -517,4 +517,203 @@ describe("status", () => {
517
517
  cleanup();
518
518
  }
519
519
  });
520
+
521
+ describe("install-source surface (hub#243)", () => {
522
+ test("renders SOURCE column header + per-row label", async () => {
523
+ const { path, cleanup } = makeTempPath();
524
+ try {
525
+ upsertService(
526
+ {
527
+ name: "parachute-vault",
528
+ port: 1940,
529
+ paths: ["/vault/default"],
530
+ health: "/vault/default/health",
531
+ version: "0.4.4-rc.3",
532
+ installDir: "/Users/me/code/parachute-vault",
533
+ },
534
+ path,
535
+ );
536
+ const lines: string[] = [];
537
+ await status({
538
+ manifestPath: path,
539
+ fetchImpl: async () => new Response(null, { status: 200 }),
540
+ print: (l) => lines.push(l),
541
+ installSourceDeps: {
542
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
543
+ resolveBunGlobal: () => null,
544
+ readJson: (p) =>
545
+ p === "/Users/me/code/parachute-vault/package.json"
546
+ ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
547
+ : (() => {
548
+ throw new Error("nope");
549
+ })(),
550
+ readGitHead: () => "8aa167b",
551
+ },
552
+ });
553
+ expect(lines[0]).toMatch(/SOURCE/);
554
+ expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
555
+ } finally {
556
+ cleanup();
557
+ }
558
+ });
559
+
560
+ test("STALE continuation line fires when bun-linked live version != cached version", async () => {
561
+ // Reproduces hub#243's motivating case: services.json says 0.3.11-rc.1
562
+ // but the live source has been rebuilt to 0.3.15-rc.1. Operator should
563
+ // see STALE in one glance from `parachute status` output.
564
+ const { path, cleanup } = makeTempPath();
565
+ try {
566
+ upsertService(
567
+ {
568
+ name: "parachute-notes",
569
+ port: 1942,
570
+ paths: ["/notes"],
571
+ health: "/notes/health",
572
+ version: "0.3.11-rc.1",
573
+ installDir: "/Users/me/code/parachute-notes",
574
+ },
575
+ path,
576
+ );
577
+ const lines: string[] = [];
578
+ await status({
579
+ manifestPath: path,
580
+ fetchImpl: async () => new Response(null, { status: 200 }),
581
+ print: (l) => lines.push(l),
582
+ installSourceDeps: {
583
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
584
+ resolveBunGlobal: () => null,
585
+ readJson: (p) =>
586
+ p === "/Users/me/code/parachute-notes/package.json"
587
+ ? { name: "@openparachute/notes", version: "0.3.15-rc.1" }
588
+ : (() => {
589
+ throw new Error("nope");
590
+ })(),
591
+ readGitHead: () => "051c404",
592
+ },
593
+ });
594
+ expect(
595
+ lines.some((l) =>
596
+ l.includes("STALE: services.json cached 0.3.11-rc.1; live package.json 0.3.15-rc.1"),
597
+ ),
598
+ ).toBe(true);
599
+ } finally {
600
+ cleanup();
601
+ }
602
+ });
603
+
604
+ test("npm-installed services render as `npm (<version>)` and never STALE", async () => {
605
+ const { path, cleanup } = makeTempPath();
606
+ try {
607
+ upsertService(
608
+ {
609
+ name: "parachute-scribe",
610
+ port: 1943,
611
+ paths: ["/scribe"],
612
+ health: "/scribe/health",
613
+ version: "0.4.2-rc.1",
614
+ installDir: "/home/test/.bun/install/global/node_modules/@openparachute/scribe",
615
+ },
616
+ path,
617
+ );
618
+ const lines: string[] = [];
619
+ await status({
620
+ manifestPath: path,
621
+ fetchImpl: async () => new Response(null, { status: 200 }),
622
+ print: (l) => lines.push(l),
623
+ installSourceDeps: {
624
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
625
+ resolveBunGlobal: () => null,
626
+ readJson: (p) =>
627
+ p === "/home/test/.bun/install/global/node_modules/@openparachute/scribe/package.json"
628
+ ? { name: "@openparachute/scribe", version: "0.4.2-rc.1" }
629
+ : (() => {
630
+ throw new Error("nope");
631
+ })(),
632
+ readGitHead: () => undefined,
633
+ },
634
+ });
635
+ expect(lines.some((l) => l.includes("npm (0.4.2-rc.1)"))).toBe(true);
636
+ expect(lines.some((l) => l.includes("STALE:"))).toBe(false);
637
+ } finally {
638
+ cleanup();
639
+ }
640
+ });
641
+
642
+ test("entries without installDir fall back to bun-global symlink lookup", async () => {
643
+ // Some services.json entries (older first-party rows, or rows written
644
+ // by a service that doesn't echo installDir) leave the field absent.
645
+ // detectInstallSource maps the entry name → first-party package and
646
+ // probes bun globals for the symlink. Pins that fallback path.
647
+ const { path, cleanup } = makeTempPath();
648
+ try {
649
+ upsertService(
650
+ {
651
+ name: "parachute-vault",
652
+ port: 1940,
653
+ paths: ["/vault/default"],
654
+ health: "/vault/default/health",
655
+ version: "0.4.4-rc.3",
656
+ // No installDir.
657
+ },
658
+ path,
659
+ );
660
+ const lines: string[] = [];
661
+ await status({
662
+ manifestPath: path,
663
+ fetchImpl: async () => new Response(null, { status: 200 }),
664
+ print: (l) => lines.push(l),
665
+ installSourceDeps: {
666
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
667
+ resolveBunGlobal: (pkg) =>
668
+ pkg === "@openparachute/vault" ? "/Users/me/code/parachute-vault" : null,
669
+ readJson: (p) =>
670
+ p === "/Users/me/code/parachute-vault/package.json"
671
+ ? { name: "@openparachute/vault", version: "0.4.4-rc.3" }
672
+ : (() => {
673
+ throw new Error("nope");
674
+ })(),
675
+ readGitHead: () => "8aa167b",
676
+ },
677
+ });
678
+ expect(lines.some((l) => l.includes("bun-linked → parachute-vault @ 8aa167b"))).toBe(true);
679
+ } finally {
680
+ cleanup();
681
+ }
682
+ });
683
+
684
+ test("third-party row without installDir + no mapping renders as 'unknown'", async () => {
685
+ const { path, cleanup } = makeTempPath();
686
+ try {
687
+ upsertService(
688
+ {
689
+ name: "agent",
690
+ port: 1946,
691
+ paths: ["/agent"],
692
+ health: "/agent/health",
693
+ version: "0.1.4-rc.1",
694
+ // No installDir; agent isn't in FIRST_PARTY_FALLBACKS by short name,
695
+ // and the fallback bun-global lookup needs a known package name.
696
+ },
697
+ path,
698
+ );
699
+ const lines: string[] = [];
700
+ await status({
701
+ manifestPath: path,
702
+ fetchImpl: async () => new Response(null, { status: 200 }),
703
+ print: (l) => lines.push(l),
704
+ installSourceDeps: {
705
+ bunGlobalPrefixes: () => ["/home/test/.bun/install/global/node_modules"],
706
+ resolveBunGlobal: () => null,
707
+ readJson: () => {
708
+ throw new Error("not reached");
709
+ },
710
+ readGitHead: () => undefined,
711
+ },
712
+ });
713
+ expect(lines.some((l) => l.includes("unknown"))).toBe(true);
714
+ } finally {
715
+ cleanup();
716
+ }
717
+ });
718
+ });
520
719
  });