Public: anyone can read this; the crew can change it.
A crew run can open its own preview on a private repository #7832
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7829-a-crew-run-can-open-its-own-preview-on-a → main 2 commits fork
4 files
changed +395
−4
docs/APPS.md +4
| @@ -197,6 +197,10 @@ Under the box, four starter sentences are plain links (no script): | ||
| 197 | 197 | to choose. |
| 198 | 198 | - A **private** app answers only to someone the forge has vouched for |
| 199 | 199 | (`canRead` on the repository, then a signed grant); a public one to anyone. |
| 200 | A scripted caller needs no browser: `Authorization: Bearer <token>` on the app's host | |
| 201 | admits a token whose person may enter the app (a run's token only on its own | |
| 202 | repository), and the header is stripped before the app sees the request. A | |
| 203 | reader who may not enter is told so in the 404; anyone else gets the plain one. | |
| 200 | 204 | - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
| 201 | 205 | `tty` command from `forge.json` on a real pty over the environment's own |
| 202 | 206 | data; `<name>--<pull>@<forge>` opens a preview's. The scaffold ships one. |
src/apps/proxy.ts +118 −2
| @@ -33,6 +33,8 @@ import { | ||
| 33 | 33 | type Repo, |
| 34 | 34 | ownerInStanding, |
| 35 | 35 | } from "../core/repos.ts"; |
| 36 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 37 | import { verifyToken } from "../core/tokens.ts"; | |
| 36 | 38 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 37 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 38 | 40 | import { loadKey } from "../kernel/secret.ts"; |
| @@ -548,6 +550,79 @@ function stillEntitled( | ||
| 548 | 550 | ); |
| 549 | 551 | } |
| 550 | 552 | |
| 553 | /** What the app's host tells a signed-in reader it may not enter. */ | |
| 554 | export const NOT_ENTITLED_WORDS = | |
| 555 | "not found: this token can read the repository but may not enter its app. Ask a writer of the repository to add you to the app, or use a token of a writer or of the crew."; | |
| 556 | ||
| 557 | const BEARER_GRANT_MS = 60_000; | |
| 558 | /** Failed tokens one client may present per minute, across every app host. */ | |
| 559 | const BEARER_FAILURES_PER_MINUTE = 30; | |
| 560 | const failedTokens = new WeakMap<App, RateLimiter>(); | |
| 561 | ||
| 562 | /** | |
| 563 | * A bearer token on a request to a guarded app, read the way the forge's | |
| 564 | * own API reads it. It admits when its person may enter the app; a run's | |
| 565 | * token only on the repository it was minted for. A person who can read | |
| 566 | * the repository but not enter is told so (`hint`); anyone else — a token | |
| 567 | * that cannot read it, an expired one — gets nothing to learn from. | |
| 568 | */ | |
| 569 | function bearerAdmission( | |
| 570 | runtime: AppsRuntime, | |
| 571 | request: Request, | |
| 572 | target: AppTarget, | |
| 573 | ): { | |
| 574 | grant?: Grant; | |
| 575 | hint?: string; | |
| 576 | readOnly?: boolean; | |
| 577 | retryAfter?: number; | |
| 578 | } | undefined { | |
| 579 | const header = request.headers.get("authorization"); | |
| 580 | if (header === null || !header.startsWith("Bearer ")) return undefined; | |
| 581 | if ( | |
| 582 | target.app.access !== "private" && | |
| 583 | sealedPull(runtime, target) === undefined | |
| 584 | ) | |
| 585 | return undefined; | |
| 586 | // Counted before the lookup, given back when the token is good: only | |
| 587 | // failures use up the allowance. | |
| 588 | let limiter = failedTokens.get(runtime.app); | |
| 589 | if (limiter === undefined) { | |
| 590 | limiter = new RateLimiter({ | |
| 591 | perMinute: BEARER_FAILURES_PER_MINUTE, | |
| 592 | adminPerMinute: BEARER_FAILURES_PER_MINUTE, | |
| 593 | enabled: true, | |
| 594 | }); | |
| 595 | failedTokens.set(runtime.app, limiter); | |
| 596 | } | |
| 597 | const key = clientBucket(request); | |
| 598 | const allowed = limiter.take(key, "ro"); | |
| 599 | if (!allowed.allowed) return { retryAfter: allowed.retryAfter }; | |
| 600 | const verified = verifyToken(runtime.app.db, header.slice(7)); | |
| 601 | if (!verified.ok) return undefined; | |
| 602 | limiter.refund(key); | |
| 603 | const { user, run, scope } = verified.value; | |
| 604 | const repo = getRepoById(runtime.app.db, target.app.repoId); | |
| 605 | if (repo === undefined) return undefined; | |
| 606 | if (run !== undefined && run.repoId !== repo.id && run.forkId !== repo.id) | |
| 607 | return undefined; | |
| 608 | if (!canRead(runtime.app.db, repo, user)) return undefined; | |
| 609 | const grant: Grant = { | |
| 610 | who: { | |
| 611 | id: user.id, | |
| 612 | name: user.username, | |
| 613 | // A read-only token is never an admin inside the app. | |
| 614 | role: | |
| 615 | scope !== "ro" && canWrite(runtime.app.db, repo, user) | |
| 616 | ? "admin" | |
| 617 | : "user", | |
| 618 | }, | |
| 619 | expiresMs: Date.now() + BEARER_GRANT_MS, | |
| 620 | }; | |
| 621 | return stillEntitled(runtime, target, grant) | |
| 622 | ? { grant, readOnly: scope === "ro" } | |
| 623 | : { hint: NOT_ENTITLED_WORDS }; | |
| 624 | } | |
| 625 | ||
| 551 | 626 | /** |
| 552 | 627 | * What a request addressed to an app must pass before anything is served: |
| 553 | 628 | * a private app's grant, then a live port. One gate for both the request |
| @@ -560,10 +635,40 @@ function admit( | ||
| 560 | 635 | target: AppTarget, |
| 561 | 636 | ): Result<{ status: EnvStatus; port: number; grant?: Grant }, Response> { |
| 562 | 637 | const cookie = cookieValue(request, grantCookieName(target)); |
| 563 | const signed = | |
| 638 | const cookieGrant = | |
| 564 | 639 | cookie === undefined |
| 565 | 640 | ? undefined |
| 566 | 641 | : readGrant(runtime.app.config.dataDir, cookie, target); |
| 642 | // No browser, no cookie: a bearer token of someone who may enter is as | |
| 643 | // good as a grant for this one request (#7829). | |
| 644 | const bearer = | |
| 645 | cookieGrant === undefined | |
| 646 | ? bearerAdmission(runtime, request, target) | |
| 647 | : undefined; | |
| 648 | if (bearer?.retryAfter !== undefined) | |
| 649 | return err( | |
| 650 | new Response("too many failed tokens", { | |
| 651 | status: 429, | |
| 652 | headers: { | |
| 653 | "retry-after": String(bearer.retryAfter), | |
| 654 | "cache-control": "no-store", | |
| 655 | }, | |
| 656 | }), | |
| 657 | ); | |
| 658 | // A read-only token reads: the forge's API refuses it writes, and the | |
| 659 | // app's door is no wider. | |
| 660 | if ( | |
| 661 | bearer?.readOnly === true && | |
| 662 | request.method !== "GET" && | |
| 663 | request.method !== "HEAD" | |
| 664 | ) | |
| 665 | return err( | |
| 666 | new Response("this token is read-only", { | |
| 667 | status: 403, | |
| 668 | headers: { "cache-control": "no-store" }, | |
| 669 | }), | |
| 670 | ); | |
| 671 | const signed = cookieGrant ?? bearer?.grant; | |
| 567 | 672 | // A private app's grant is asked again on every request whether the |
| 568 | 673 | // person it names may still enter: a grant outlives a change of heart — |
| 569 | 674 | // a writer removed, the app made private — by up to twelve hours, and |
| @@ -594,6 +699,10 @@ function admit( | ||
| 594 | 699 | // gets that answer here. |
| 595 | 700 | const url = new URL(request.url); |
| 596 | 701 | const navigating = |
| 702 | bearer?.hint === undefined && | |
| 703 | // A caller that presented a token is a script, not a person at a | |
| 704 | // link: it is answered, not sent to sign in. | |
| 705 | !(request.headers.get("authorization") ?? "").startsWith("Bearer ") && | |
| 597 | 706 | (request.method === "GET" || request.method === "HEAD") && |
| 598 | 707 | (request.headers.get("accept") ?? "").includes("text/html") && |
| 599 | 708 | !url.pathname.startsWith("/__forge/"); |
| @@ -609,7 +718,14 @@ function admit( | ||
| 609 | 718 | }), |
| 610 | 719 | ); |
| 611 | 720 | } |
| 612 | return err(new Response("not found", { status: 404 })); | |
| 721 | return err( | |
| 722 | new Response(bearer?.hint ?? "not found", { | |
| 723 | status: 404, | |
| 724 | ...(bearer?.hint === undefined | |
| 725 | ? {} | |
| 726 | : { headers: { "content-type": "text/plain; charset=utf-8" } }), | |
| 727 | }), | |
| 728 | ); | |
| 613 | 729 | } |
| 614 | 730 | } |
| 615 | 731 | // An archived repository's app is put away: stopped, its data kept, and |
src/crew/brief.ts +2 −2
| @@ -179,7 +179,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 179 | 179 | ...(input.previewUrl === undefined |
| 180 | 180 | ? [] |
| 181 | 181 | : [ |
| 182 | `- The preview of your pull is at ${input.previewUrl} once it is up.`, | |
| 182 | `- The preview of your pull is at ${input.previewUrl} once it is up; fetch it, private repository or not, with \`curl -H "authorization: Bearer $FORGE_TOKEN" ${input.previewUrl}\`.`, | |
| 183 | 183 | ]), |
| 184 | 184 | ...ownAppLines(input.ownApp), |
| 185 | 185 | ); |
| @@ -427,7 +427,7 @@ function taskBody(input: TaskInput): string { | ||
| 427 | 427 | ...(input.previewUrl === undefined |
| 428 | 428 | ? [] |
| 429 | 429 | : [ |
| 430 | `- This pull's preview, at this head, is at ${input.previewUrl}. Use it as a person would: curl its pages, try the doors it offers. Judge this preview only — another pull's preview is not this change.`, | |
| 430 | `- This pull's preview, at this head, is at ${input.previewUrl}. Use it as a person would: curl its pages with \`-H "authorization: Bearer $FORGE_TOKEN"\` (it works on a private repository), try the doors it offers. Judge this preview only — another pull's preview is not this change.`, | |
| 431 | 431 | ]), |
| 432 | 432 | ...(input.looks !== undefined && input.looks.length > 0 |
| 433 | 433 | ? [ |
test/apps-preview-token.test.ts added +271
| @@ -0,0 +1,271 @@ | ||
| 1 | /** | |
| 2 | * A bearer token opens the preview of a repository its person may enter | |
| 3 | * (#7829): a crew run fetches its own pull's preview with the FORGE_TOKEN | |
| 4 | * it holds, no browser. A token that cannot read the repository learns | |
| 5 | * nothing; the token itself never reaches the app. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { openIssue } from "../src/actions/issues.ts"; | |
| 10 | import { newApp, updateAppSettings } from "../src/actions/apps.ts"; | |
| 11 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 12 | import type { App } from "../src/app.ts"; | |
| 13 | import { NOT_ENTITLED_WORDS } from "../src/apps/proxy.ts"; | |
| 14 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { mintRunToken } from "../src/core/run_token.ts"; | |
| 17 | import { createIssue } from "../src/core/issues.ts"; | |
| 18 | import { createToken } from "../src/core/tokens.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { systemPrompt, type BriefInput } from "../src/crew/brief.ts"; | |
| 21 | import { | |
| 22 | deploySettled, | |
| 23 | fetchAsHost, | |
| 24 | startAppsForge, | |
| 25 | stopAppsForge, | |
| 26 | type AppsForge, | |
| 27 | } from "./helpers/apps-forge.ts"; | |
| 28 | ||
| 29 | const SERVER_SUITES = [ | |
| 30 | "src/audit.test.ts", | |
| 31 | "src/openapi.test.ts", | |
| 32 | "src/security.test.ts", | |
| 33 | "src/site.test.ts", | |
| 34 | ]; | |
| 35 | ||
| 36 | describe("a bearer token on an app's host", () => { | |
| 37 | let forge: AppsForge; | |
| 38 | let app: App; | |
| 39 | let alice: User; | |
| 40 | let bob: User; | |
| 41 | let repo: Repo; | |
| 42 | let record: AppRecord; | |
| 43 | let open: { repo: Repo; record: AppRecord }; | |
| 44 | const host = () => `vault.${forge.runtime.options.domain}`; | |
| 45 | const get = (h: string, token?: string, accept = "application/json") => | |
| 46 | fetchAsHost(forge.servers, h, "/", { | |
| 47 | headers: { | |
| 48 | accept, | |
| 49 | ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), | |
| 50 | }, | |
| 51 | }); | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | forge = await startAppsForge(); | |
| 55 | ({ app, alice } = forge); | |
| 56 | const made = await createUser(app.db, { | |
| 57 | origin: "seed", | |
| 58 | trusted: true, | |
| 59 | username: "bob", | |
| 60 | email: "[email protected]", | |
| 61 | password: "a-good-passphrase", | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(made.error); | |
| 64 | bob = made.value; | |
| 65 | const vault = await newApp(app, alice, { | |
| 66 | name: "vault", | |
| 67 | description: "Private", | |
| 68 | visibility: "private", | |
| 69 | }); | |
| 70 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 71 | ({ repo, app: record } = vault.value); | |
| 72 | expect(record.access).toBe("private"); | |
| 73 | await deploySettled(app, repo.id, (r) => r.env === "production"); | |
| 74 | const committed = await commitToBranch(app, alice, repo, { | |
| 75 | message: "Echo the request", | |
| 76 | files: [ | |
| 77 | ...SERVER_SUITES.map((path) => ({ path, content: null })), | |
| 78 | { | |
| 79 | path: "src/server.test.ts", | |
| 80 | content: | |
| 81 | 'import { expect, test } from "bun:test";\ntest("passes", () => { expect(1).toBe(1); });\n', | |
| 82 | }, | |
| 83 | { | |
| 84 | path: "src/server.ts", | |
| 85 | content: `Bun.serve({ | |
| 86 | port: Number(process.env.PORT), | |
| 87 | hostname: "127.0.0.1", | |
| 88 | fetch(request) { | |
| 89 | return Response.json({ | |
| 90 | page: "secret page", | |
| 91 | authorization: request.headers.get("authorization"), | |
| 92 | identity: request.headers.get("x-forge-identity"), | |
| 93 | }); | |
| 94 | }, | |
| 95 | }); | |
| 96 | `, | |
| 97 | }, | |
| 98 | ], | |
| 99 | }); | |
| 100 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 101 | const run = await deploySettled( | |
| 102 | app, | |
| 103 | repo.id, | |
| 104 | (r) => r.env === "production" && r.commitOid === committed.value.commitOid, | |
| 105 | ); | |
| 106 | if (run.state !== "ok") throw new Error(run.summary); | |
| 107 | // A public repository whose app is private: its readers may read the code, not enter. | |
| 108 | const second = await newApp(app, alice, { | |
| 109 | name: "lobby", | |
| 110 | description: "Public code", | |
| 111 | visibility: "public", | |
| 112 | }); | |
| 113 | if (!second.ok) throw new Error(String(second.error)); | |
| 114 | open = { repo: second.value.repo, record: second.value.app }; | |
| 115 | updateAppSettings(app, alice, open.repo, { access: "private" }); | |
| 116 | }, 120_000); | |
| 117 | ||
| 118 | afterAll(async () => { | |
| 119 | await forge.runtime.stop(); | |
| 120 | await stopAppsForge(forge); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a reader's token sees the page; the token never reaches the app", async () => { | |
| 124 | const token = createToken(app.db, alice.id, "t", "ro").token; | |
| 125 | const res = await get(host(), token); | |
| 126 | expect(res.status).toBe(200); | |
| 127 | const heard = (await res.json()) as { page: string; authorization: string | null }; | |
| 128 | expect(heard.page).toBe("secret page"); | |
| 129 | expect(heard.authorization).toBeNull(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("no token, a stranger's token and a made-up token get the plain 404", async () => { | |
| 133 | const stranger = createToken(app.db, bob.id, "t", "ro").token; | |
| 134 | for (const token of [undefined, stranger, "forge_nope"]) { | |
| 135 | const res = await get(host(), token); | |
| 136 | expect(res.status).toBe(404); | |
| 137 | expect(await res.text()).toBe("not found"); | |
| 138 | } | |
| 139 | }); | |
| 140 | ||
| 141 | test("a run token works on its own repository; an expired one does not", async () => { | |
| 142 | const live = mintRunToken( | |
| 143 | app.db, | |
| 144 | alice.id, | |
| 145 | "run1", | |
| 146 | { repoId: repo.id, forkId: repo.id }, | |
| 147 | new Date(Date.now() + 3_600_000).toISOString(), | |
| 148 | ).token; | |
| 149 | expect((await get(host(), live)).status).toBe(200); | |
| 150 | const expired = mintRunToken( | |
| 151 | app.db, | |
| 152 | alice.id, | |
| 153 | "run2", | |
| 154 | { repoId: repo.id, forkId: repo.id }, | |
| 155 | new Date(Date.now() - 1000).toISOString(), | |
| 156 | ).token; | |
| 157 | const res = await get(host(), expired); | |
| 158 | expect(res.status).toBe(404); | |
| 159 | expect(await res.text()).toBe("not found"); | |
| 160 | // Bound to another repository, it opens nothing here. | |
| 161 | const elsewhere = mintRunToken( | |
| 162 | app.db, | |
| 163 | alice.id, | |
| 164 | "run3", | |
| 165 | { repoId: open.repo.id, forkId: open.repo.id }, | |
| 166 | new Date(Date.now() + 3_600_000).toISOString(), | |
| 167 | ).token; | |
| 168 | expect((await get(host(), elsewhere)).status).toBe(404); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a reader who may not enter is told what to do; a stranger is not", async () => { | |
| 172 | const lobby = `lobby.${forge.runtime.options.domain}`; | |
| 173 | const reader = createToken(app.db, bob.id, "t", "ro").token; | |
| 174 | for (const accept of ["application/json", "text/html"]) { | |
| 175 | const told = await get(lobby, reader, accept); | |
| 176 | expect(told.status).toBe(404); | |
| 177 | expect(await told.text()).toBe(NOT_ENTITLED_WORDS); | |
| 178 | } | |
| 179 | const none = await get(lobby); | |
| 180 | expect(await none.text()).toBe("not found"); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a read-only token of a writer is a user in the app, and reads only", async () => { | |
| 184 | const ro = createToken(app.db, alice.id, "t", "ro").token; | |
| 185 | const rw = createToken(app.db, alice.id, "t", "rw").token; | |
| 186 | const roleOf = async (token: string) => { | |
| 187 | const res = await get(host(), token); | |
| 188 | const heard = (await res.json()) as { identity: string }; | |
| 189 | return heard.identity.split("|")[2]; | |
| 190 | }; | |
| 191 | expect(await roleOf(ro)).toBe("user"); | |
| 192 | expect(await roleOf(rw)).toBe("admin"); | |
| 193 | const post = (token: string) => | |
| 194 | fetchAsHost(forge.servers, host(), "/", { | |
| 195 | method: "POST", | |
| 196 | headers: { authorization: `Bearer ${token}`, accept: "application/json" }, | |
| 197 | body: "x", | |
| 198 | }); | |
| 199 | expect((await post(ro)).status).toBe(403); | |
| 200 | expect((await post(rw)).status).toBe(200); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a token that cannot read gets the plain 404 on a page navigation, not a redirect or a hint", async () => { | |
| 204 | const stranger = createToken(app.db, bob.id, "t", "ro").token; | |
| 205 | const res = await get(host(), stranger, "text/html"); | |
| 206 | expect(res.status).toBe(404); | |
| 207 | expect(res.headers.get("location")).toBeNull(); | |
| 208 | expect(await res.text()).toBe("not found"); | |
| 209 | }); | |
| 210 | ||
| 211 | test("a sealed pull's preview on a public app admits a writer's token and tells a reader's why not", async () => { | |
| 212 | const plaza = await newApp(app, alice, { | |
| 213 | name: "plaza", | |
| 214 | description: "Public", | |
| 215 | visibility: "public", | |
| 216 | }); | |
| 217 | if (!plaza.ok) throw new Error(String(plaza.error)); | |
| 218 | const report = openIssue(app, alice, plaza.value.repo, { | |
| 219 | title: "Hole", | |
| 220 | sealed: true, | |
| 221 | }); | |
| 222 | if (!report.ok) throw new Error(String(report.error)); | |
| 223 | const sealed = createIssue(app.db, { | |
| 224 | repoId: plaza.value.repo.id, | |
| 225 | authorId: alice.id, | |
| 226 | title: "Fix", | |
| 227 | body: `Closes #${report.value.number}`, | |
| 228 | kind: "pull", | |
| 229 | sealed: true, | |
| 230 | }); | |
| 231 | if (!sealed.ok) throw new Error("pull"); | |
| 232 | const sealedHost = forge.runtime.hostFor( | |
| 233 | plaza.value.app.slug, | |
| 234 | `pull-${sealed.value.number}` as never, | |
| 235 | ); | |
| 236 | const reader = createToken(app.db, bob.id, "t", "ro").token; | |
| 237 | const told = await get(sealedHost, reader); | |
| 238 | expect(told.status).toBe(404); | |
| 239 | expect(await told.text()).toBe(NOT_ENTITLED_WORDS); | |
| 240 | const writer = createToken(app.db, alice.id, "t", "ro").token; | |
| 241 | const past = await get(sealedHost, writer); | |
| 242 | expect(await past.text()).not.toBe(NOT_ENTITLED_WORDS); | |
| 243 | }); | |
| 244 | ||
| 245 | test("the brief says how a run opens its preview", () => { | |
| 246 | const prompt = systemPrompt({ | |
| 247 | agent: { user: { username: "bot", displayName: "" }, brief: "" }, | |
| 248 | repo: { ownerName: "alice", name: "square", defaultBranch: "main" }, | |
| 249 | issue: { number: 1 }, | |
| 250 | branch: "bot/1", | |
| 251 | forgeUrl: "http://forge.test", | |
| 252 | isApp: true, | |
| 253 | previewUrl: "http://square--2.apps.test/", | |
| 254 | } as unknown as BriefInput); | |
| 255 | expect(prompt).toContain( | |
| 256 | 'curl -H "authorization: Bearer $FORGE_TOKEN" http://square--2.apps.test/', | |
| 257 | ); | |
| 258 | }); | |
| 259 | ||
| 260 | test("a client that keeps presenting bad tokens is slowed down; a good token is not counted", async () => { | |
| 261 | let last = 0; | |
| 262 | for (let i = 0; i < 40; i++) { | |
| 263 | last = (await get(host(), `forge_bad${i}`)).status; | |
| 264 | if (last === 429) break; | |
| 265 | } | |
| 266 | expect(last).toBe(429); | |
| 267 | const res = await get(host(), "forge_more"); | |
| 268 | expect(res.status).toBe(429); | |
| 269 | expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 270 | }); | |
| 271 | }); |
new above
offline