@cosmicdrift/kumiko-dev-server 0.157.2 → 0.159.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/package.json +4 -4
- package/src/__tests__/compose-stacks.test.ts +13 -3
- package/src/__tests__/saas-identity-wire.integration.test.ts +13 -4
- package/src/__tests__/scaffold-app.test.ts +1 -0
- package/src/__tests__/walkthrough.integration.test.ts +21 -5
- package/src/run-dev-app.ts +23 -18
- package/src/scaffold-app.ts +18 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.1",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
58
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
59
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
57
|
+
"@cosmicdrift/kumiko-bundled-features": "0.159.1",
|
|
58
|
+
"@cosmicdrift/kumiko-framework": "0.159.1",
|
|
59
|
+
"@cosmicdrift/kumiko-server-runtime": "0.159.1",
|
|
60
60
|
"ts-morph": "^28.0.0"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
|
+
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
2
4
|
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
5
|
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
4
6
|
import {
|
|
@@ -110,9 +112,15 @@ describe("composeStacks", () => {
|
|
|
110
112
|
expect(names.filter((n) => n === "sessions")).toHaveLength(2);
|
|
111
113
|
expect(() =>
|
|
112
114
|
validateBoot(
|
|
113
|
-
composeFeatures(
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
composeFeatures(
|
|
116
|
+
[
|
|
117
|
+
authFoundationFeature,
|
|
118
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
119
|
+
...composeIdentityStack(),
|
|
120
|
+
...composeGdprStack({ sessions: true }),
|
|
121
|
+
],
|
|
122
|
+
{ includeBundled: true },
|
|
123
|
+
),
|
|
116
124
|
),
|
|
117
125
|
).toThrow(/duplicate feature/i);
|
|
118
126
|
});
|
|
@@ -123,6 +131,8 @@ describe("composeStacks boots for real", () => {
|
|
|
123
131
|
test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
|
|
124
132
|
const features = composeFeatures(
|
|
125
133
|
[
|
|
134
|
+
authFoundationFeature,
|
|
135
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
126
136
|
...composeOpsStack({ rateLimiting: true }),
|
|
127
137
|
...composePagesStack(),
|
|
128
138
|
...composeMailStack({ transports: ["inmemory"] }),
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
seedAdmin,
|
|
13
13
|
seedUserWithPassword,
|
|
14
14
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
15
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
15
16
|
import {
|
|
16
17
|
AuthMfaHandlers,
|
|
17
18
|
base32Decode,
|
|
@@ -94,6 +95,13 @@ async function markVerified(userId: string): Promise<void> {
|
|
|
94
95
|
await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// writeOk drives real HTTP through the auth middleware, so a hand-built
|
|
99
|
+
// actor needs a live sid — sessionChecker rejects sidless JWTs.
|
|
100
|
+
async function withSession(user: SessionUser): Promise<SessionUser> {
|
|
101
|
+
const sid = await callbacks.get().sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
|
|
102
|
+
return { ...user, sid };
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
beforeAll(async () => {
|
|
98
106
|
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
99
107
|
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
@@ -110,6 +118,7 @@ beforeAll(async () => {
|
|
|
110
118
|
|
|
111
119
|
const features = composeFeatures(
|
|
112
120
|
[
|
|
121
|
+
authFoundationFeature,
|
|
113
122
|
...identity,
|
|
114
123
|
...composeOpsStack({ delivery: true, audit: false, jobs: false }),
|
|
115
124
|
...composeRendererStack(),
|
|
@@ -147,8 +156,8 @@ beforeAll(async () => {
|
|
|
147
156
|
}),
|
|
148
157
|
authConfig: {
|
|
149
158
|
...bound.asAuthConfig(),
|
|
150
|
-
//
|
|
151
|
-
//
|
|
159
|
+
// sessionChecker rejects sidless JWTs — hand-built actors used with
|
|
160
|
+
// writeOk go through withSession() to get a live sid first.
|
|
152
161
|
membershipQuery: "tenant:query:memberships",
|
|
153
162
|
loginHandler: AuthHandlers.login,
|
|
154
163
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
@@ -293,7 +302,7 @@ describe("saas-identity-wire", () => {
|
|
|
293
302
|
});
|
|
294
303
|
await markVerified(adminId);
|
|
295
304
|
|
|
296
|
-
const admin
|
|
305
|
+
const admin = await withSession({ id: adminId, tenantId, roles: ["Admin"] });
|
|
297
306
|
const invitee = "carol-wire@example.com";
|
|
298
307
|
await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
|
|
299
308
|
expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
|
|
@@ -368,7 +377,7 @@ describe("saas-identity-wire", () => {
|
|
|
368
377
|
],
|
|
369
378
|
});
|
|
370
379
|
await markVerified(userId);
|
|
371
|
-
const user
|
|
380
|
+
const user = await withSession({ id: userId, tenantId, roles: ["User"] });
|
|
372
381
|
|
|
373
382
|
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
374
383
|
AuthMfaHandlers.enableStart,
|
|
@@ -92,6 +92,7 @@ describe("scaffoldApp", () => {
|
|
|
92
92
|
|
|
93
93
|
const kumikoBin = readFileSync(join(dest, "bin/kumiko.ts"), "utf-8");
|
|
94
94
|
expect(kumikoBin).toContain("runSchemaCli");
|
|
95
|
+
expect(kumikoBin).toContain("runConsumerCli");
|
|
95
96
|
expect(kumikoBin).toContain("includeBundled: HAS_AUTH");
|
|
96
97
|
});
|
|
97
98
|
|
|
@@ -13,6 +13,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
13
13
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
|
+
import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
16
18
|
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
17
19
|
import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
18
20
|
import { createRegistry, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -77,18 +79,32 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
77
79
|
// a dummy defineFeature here — the scaffold-side of "notesFeature"
|
|
78
80
|
// (file-content) is pinned in test 2; this test pins the runtime-side
|
|
79
81
|
// (composeFeatures auto-prepend behaviour the walkthrough claims).
|
|
82
|
+
//
|
|
83
|
+
// sessions now requires auth-foundation (#1370/#1371), which itself
|
|
84
|
+
// needs a tokenVerifier provider mounted (#1368) — PAT here, mirroring
|
|
85
|
+
// the scaffold picker's real-world default. Not part of the walkthrough's
|
|
86
|
+
// advertised 7-feature count (that count is about includeBundled's
|
|
87
|
+
// auto-mount, unrelated to this coupling), so both are added on top.
|
|
80
88
|
const notesFeature = defineFeature("notes", () => {});
|
|
81
|
-
const APP_FEATURES = [
|
|
89
|
+
const APP_FEATURES = [
|
|
90
|
+
createSecretsFeature(),
|
|
91
|
+
authFoundationFeature,
|
|
92
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
93
|
+
createSessionsFeature(),
|
|
94
|
+
notesFeature,
|
|
95
|
+
];
|
|
82
96
|
|
|
83
97
|
const composed = composeFeatures(APP_FEATURES, { includeBundled: true });
|
|
84
|
-
//
|
|
85
|
-
expect(composed.length).toBe(
|
|
98
|
+
// 5 explicit + 4 auto-mounted bundled = 9 total features.
|
|
99
|
+
expect(composed.length).toBe(9);
|
|
86
100
|
|
|
87
101
|
const composedNames = composed.map((f) => f.name).sort();
|
|
88
102
|
expect(composedNames).toEqual([
|
|
89
103
|
"auth-email-password",
|
|
104
|
+
"auth-foundation",
|
|
90
105
|
"config",
|
|
91
106
|
"notes",
|
|
107
|
+
"personal-access-tokens",
|
|
92
108
|
"secrets",
|
|
93
109
|
"sessions",
|
|
94
110
|
"tenant",
|
|
@@ -97,9 +113,9 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
97
113
|
|
|
98
114
|
// validateBoot must pass (no missing-requires, no schema-errors).
|
|
99
115
|
expect(() => validateBoot(composed)).not.toThrow();
|
|
100
|
-
// Registry must contain all
|
|
116
|
+
// Registry must contain all 9 features.
|
|
101
117
|
const registry = createRegistry(composed);
|
|
102
|
-
expect(registry.features.size).toBe(
|
|
118
|
+
expect(registry.features.size).toBe(9);
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", async () => {
|
package/src/run-dev-app.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SeedAdminOptions,
|
|
19
19
|
seedAdmin,
|
|
20
20
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
21
|
+
import { resolveTokenVerifier } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
21
22
|
import {
|
|
22
23
|
AUTH_MFA_FEATURE,
|
|
23
24
|
AuthMfaHandlers,
|
|
@@ -28,10 +29,8 @@ import {
|
|
|
28
29
|
createConfigResolver,
|
|
29
30
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
30
31
|
import {
|
|
31
|
-
createPatResolver,
|
|
32
32
|
PAT_FEATURE,
|
|
33
33
|
patRateLimitFromFeature,
|
|
34
|
-
patScopesFromFeature,
|
|
35
34
|
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
36
35
|
import {
|
|
37
36
|
bindAutoRevokeFromFeature,
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
resolveTenantLifecycleGate,
|
|
45
44
|
TENANT_LIFECYCLE_FEATURE,
|
|
46
45
|
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
47
|
-
import type {
|
|
46
|
+
import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
|
|
48
47
|
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
49
48
|
import {
|
|
50
49
|
configureBlindIndexKey,
|
|
@@ -89,6 +88,7 @@ import { renderWelcomeBanner } from "./welcome-banner";
|
|
|
89
88
|
// @cosmicdrift/kumiko-server-runtime (single source of truth) — hier nur
|
|
90
89
|
// durchgereicht.
|
|
91
90
|
export type {
|
|
91
|
+
AccountUnlockSetup,
|
|
92
92
|
AuthMailOptions,
|
|
93
93
|
EmailVerificationSetup,
|
|
94
94
|
InviteSetup,
|
|
@@ -97,6 +97,7 @@ export type {
|
|
|
97
97
|
} from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
|
|
98
98
|
|
|
99
99
|
import type {
|
|
100
|
+
AccountUnlockSetup,
|
|
100
101
|
AuthMailOptions,
|
|
101
102
|
EmailVerificationSetup,
|
|
102
103
|
InviteSetup,
|
|
@@ -120,7 +121,7 @@ export type RunDevAppAuthOptions = {
|
|
|
120
121
|
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
121
122
|
* `createSessionsFeature()` zu `features` adden — runDevApp wired
|
|
122
123
|
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen
|
|
123
|
-
* stack.db
|
|
124
|
+
* stack.db (sidless JWTs werden dann abgelehnt).
|
|
124
125
|
*
|
|
125
126
|
* Standardverhalten ohne diese Option: stateless JWTs ohne sid,
|
|
126
127
|
* Logout ist client-side cookie-clear, Kartenhaus existing-Apps
|
|
@@ -145,6 +146,8 @@ export type RunDevAppAuthOptions = {
|
|
|
145
146
|
readonly signup?: SignupSetup;
|
|
146
147
|
/** Tenant-Invite flow (Magic-Link). Symmetric. */
|
|
147
148
|
readonly invite?: InviteSetup;
|
|
149
|
+
/** Account-unlock flow (#1266). Symmetric zu RunProdAppAuthOptions. */
|
|
150
|
+
readonly accountUnlock?: AccountUnlockSetup;
|
|
148
151
|
/** Domain attribute for both auth cookies (see
|
|
149
152
|
* AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
|
|
150
153
|
readonly cookieDomain?: string;
|
|
@@ -406,21 +409,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
406
409
|
}
|
|
407
410
|
return sessionCallbacks;
|
|
408
411
|
};
|
|
409
|
-
//
|
|
410
|
-
// db (only concrete after setupTestStack). Wired
|
|
411
|
-
//
|
|
412
|
-
let
|
|
412
|
+
// Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
|
|
413
|
+
// needs the real db+registry (only concrete after setupTestStack). Wired
|
|
414
|
+
// whenever a provider feature (personal-access-tokens today) is mounted.
|
|
415
|
+
let tokenVerifier: TokenVerifier | undefined;
|
|
413
416
|
let lifecycleDb: DbConnection | undefined;
|
|
414
417
|
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
415
418
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
416
419
|
const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
|
|
417
420
|
const patAuthFragment = patFeature
|
|
418
421
|
? {
|
|
419
|
-
|
|
420
|
-
if (!
|
|
421
|
-
throw new Error("[runDevApp]
|
|
422
|
+
tokenVerifier: (rawToken: string) => {
|
|
423
|
+
if (!tokenVerifier) {
|
|
424
|
+
throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
|
|
422
425
|
}
|
|
423
|
-
return
|
|
426
|
+
return tokenVerifier(rawToken);
|
|
424
427
|
},
|
|
425
428
|
patRateLimiter: (() => {
|
|
426
429
|
const rl = patRateLimitFromFeature(patFeature);
|
|
@@ -449,11 +452,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
449
452
|
sessionRevoker: (sid: string) => requireSessions().sessionRevoker(sid),
|
|
450
453
|
sessionChecker: (sid: string, userId: string) =>
|
|
451
454
|
requireSessions().sessionChecker(sid, userId),
|
|
452
|
-
// strict-mode: jede neue Plattform-App startet ohne legacy-
|
|
453
|
-
// JWTs ohne sid, daher safe als Default. Wer Sessions opt-in
|
|
454
|
-
// wählt, will explizite Server-side Revocation — strict-mode
|
|
455
|
-
// ist der einzige Modus der das tatsächlich erzwingt.
|
|
456
|
-
sessionStrictMode: true,
|
|
457
455
|
}
|
|
458
456
|
: {};
|
|
459
457
|
|
|
@@ -509,6 +507,12 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
509
507
|
confirmHandler: AuthHandlers.verifyEmail,
|
|
510
508
|
},
|
|
511
509
|
}),
|
|
510
|
+
...(effectiveAuth.accountUnlock && {
|
|
511
|
+
accountUnlock: {
|
|
512
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
513
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
514
|
+
},
|
|
515
|
+
}),
|
|
512
516
|
...(effectiveAuth.signup && {
|
|
513
517
|
signup: {
|
|
514
518
|
requestHandler: AuthHandlers.signupRequest,
|
|
@@ -558,7 +562,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
558
562
|
}
|
|
559
563
|
}
|
|
560
564
|
if (patFeature) {
|
|
561
|
-
|
|
565
|
+
tokenVerifier = (rawToken) =>
|
|
566
|
+
resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
|
|
562
567
|
}
|
|
563
568
|
if (effectiveAuth) {
|
|
564
569
|
await seedAdmin(stack.db, effectiveAuth.admin);
|
package/src/scaffold-app.ts
CHANGED
|
@@ -765,7 +765,7 @@ deploys. Build context = app repo root; migrations ship in \`kumiko/migrations/\
|
|
|
765
765
|
- \`kumiko/schema.ts\` — same feature set → \`ENTITY_METAS\` for \`kumiko schema\`.
|
|
766
766
|
- \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
|
|
767
767
|
- \`bin/main.ts\` — production-bootstrap (\`bun run start\`).
|
|
768
|
-
- \`bin/kumiko.ts\` — schema-CLI bundled into \`dist-server/kumiko.js\`.
|
|
768
|
+
- \`bin/kumiko.ts\` — schema + consumer-ops CLI bundled into \`dist-server/kumiko.js\`.
|
|
769
769
|
- \`docker-compose.yml\` — local Postgres + Redis for \`bun dev\`.
|
|
770
770
|
|
|
771
771
|
For full docs see https://docs.kumiko.rocks.
|
|
@@ -806,25 +806,32 @@ function renderBinKumiko(): string {
|
|
|
806
806
|
return [
|
|
807
807
|
"#!/usr/bin/env bun",
|
|
808
808
|
"",
|
|
809
|
-
"// Standalone kumiko
|
|
810
|
-
"//
|
|
811
|
-
"//
|
|
809
|
+
"// Standalone kumiko CLI for the production bundle. The deploy migrate-step",
|
|
810
|
+
"// runs `bun /app/kumiko.js schema apply`; ops runs `bun /app/kumiko.js",
|
|
811
|
+
"// consumer status|restart <name>` to recover a dead event consumer without",
|
|
812
|
+
"// ad-hoc SQL. kumiko-build bundles this file to dist-server/kumiko.js.",
|
|
812
813
|
"",
|
|
813
814
|
'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
|
|
815
|
+
'import { runConsumerCli } from "@cosmicdrift/kumiko-framework/consumer-cli";',
|
|
814
816
|
'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
|
|
815
817
|
'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
|
|
816
818
|
"",
|
|
817
819
|
"const [, , cmd, ...rest] = Bun.argv;",
|
|
818
|
-
|
|
819
|
-
"
|
|
820
|
-
|
|
821
|
-
"
|
|
820
|
+
"// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
821
|
+
"const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };",
|
|
822
|
+
"",
|
|
823
|
+
'if (cmd === "schema") {',
|
|
824
|
+
" const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
|
|
825
|
+
" process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features }));",
|
|
826
|
+
"}",
|
|
827
|
+
"",
|
|
828
|
+
'if (cmd === "consumer") {',
|
|
829
|
+
" process.exit(await runConsumerCli(rest, out));",
|
|
822
830
|
"}",
|
|
823
831
|
"",
|
|
824
|
-
"const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
|
|
825
832
|
"// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
826
|
-
"
|
|
827
|
-
"process.exit(
|
|
833
|
+
'console.error("\\n Unknown: kumiko " + (cmd ?? "") + " — only \'kumiko schema <sub>\' or \'kumiko consumer <sub>\' in the standalone bundle.\\n");',
|
|
834
|
+
"process.exit(1);",
|
|
828
835
|
"",
|
|
829
836
|
].join("\n");
|
|
830
837
|
}
|