Public: anyone can read this; the crew can change it.
Spawn action: create the child org and instance, copy the chosen apps and crew briefs into it #440
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/421-spawn-action-create-the-child-org-and-in → main 1 commit fork
4 files
changed +397
−1
docs/SPAWN.md +3
| @@ -17,6 +17,9 @@ and user accounts. The child mints its own. A test in #420 and #422 proves it. | ||
| 17 | 17 | | #420 | spawn plan: one validated value, dry-run | #383 | |
| 18 | 18 | | #427 | placement seam; hosted inside the parent today | #420 | |
| 19 | 19 | | #421 | spawn action: org, instance, apps as repos, briefs | #420, #427 | |
| 20 | ||
| 21 | `src/actions/spawn.ts` (#421): the child lives in `<data>/children/<bodyId>`; the parent keeps a ledger line in `<data>/spawn/<name>.json` so a retry resumes or answers. Subscription names are in the plan but not yet created in the child (a subscription is a token). | |
| 22 | ||
| 20 | 23 | | #422 | first boot: direction, own tokens, domain, claim link | #421 | |
| 21 | 24 | | #423 | cross-forge following over HTTPS, read-only token | #422 | |
| 22 | 25 | | #424 | propose upstream as a pull on the parent | #423 | |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts test/spawn.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/actions/spawn.ts added +254
| @@ -0,0 +1,254 @@ | ||
| 1 | /** | |
| 2 | * Spawn: make the child a plan describes. A new forge instance placed by the | |
| 3 | * placement seam, an org in it, the chosen apps as repositories with their | |
| 4 | * whole history, the crew's briefs, and one `parent.json` pointing back — | |
| 5 | * the upstream URL and nothing secret. Owner-only, same-origin, and the plan | |
| 6 | * is validated again here: the dry-run is a preview, never a permit. | |
| 7 | * | |
| 8 | * Everything the copy writes lives under the child's own data directory | |
| 9 | * (`<data>/children/<bodyId>`). The parent keeps one ledger line per child | |
| 10 | * (`<data>/spawn/<name>.json`) written before the first copy, so a retry | |
| 11 | * finds the half-made child and resumes it — every step below is | |
| 12 | * idempotent — and a finished one is answered, not made twice. | |
| 13 | */ | |
| 14 | ||
| 15 | import { mkdir, rename } from "node:fs/promises"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { createApp, type App } from "../app.ts"; | |
| 18 | import { getAgentByName, createAgentRow, updateAgent, BRIEF_MAX } from "../core/agents.ts"; | |
| 19 | import { createOrg, getOrg } from "../core/orgs.ts"; | |
| 20 | import { canRead, createRepo, getRepo } from "../core/repos.ts"; | |
| 21 | import { createUser, getUserByName, type User } from "../core/users.ts"; | |
| 22 | import { runGit } from "../git/env.ts"; | |
| 23 | import { repoDir } from "../git/repos.ts"; | |
| 24 | import { sha256 } from "../kernel/hash.ts"; | |
| 25 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 26 | import { crossOrigin } from "../http/context.ts"; | |
| 27 | import type { Placement } from "../spawn/placement.ts"; | |
| 28 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; | |
| 29 | import { requireUser } from "./access.ts"; | |
| 30 | ||
| 31 | export interface Spawned { | |
| 32 | readonly name: string; | |
| 33 | readonly org: string; | |
| 34 | readonly url: string; | |
| 35 | readonly bodyId: string; | |
| 36 | /** True when this call found the child already made and changed nothing. */ | |
| 37 | readonly existed: boolean; | |
| 38 | } | |
| 39 | ||
| 40 | export type SpawnError = | |
| 41 | | { readonly kind: "unauthorized" | "forbidden" | "cross_origin" } | |
| 42 | | { readonly kind: "invalid"; readonly field: string; readonly reason: string } | |
| 43 | | { readonly kind: "refused"; readonly reason: string; readonly message: string } | |
| 44 | | { readonly kind: "exists"; readonly message: string } | |
| 45 | | { readonly kind: "failed"; readonly message: string }; | |
| 46 | ||
| 47 | export interface SpawnDeps { | |
| 48 | readonly placement: Placement; | |
| 49 | /** The request that asked, for the same-origin check on this mutation. */ | |
| 50 | readonly request: Request; | |
| 51 | readonly url: URL; | |
| 52 | } | |
| 53 | ||
| 54 | interface Ledger { | |
| 55 | readonly name: string; | |
| 56 | readonly bodyId: string; | |
| 57 | readonly url: string; | |
| 58 | readonly planHash: string; | |
| 59 | done: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; | |
| 63 | const inFlight = new Set<string>(); | |
| 64 | ||
| 65 | const fail = (message: string) => err<SpawnError>({ kind: "failed", message }); | |
| 66 | ||
| 67 | export const childDirOf = (app: App, bodyId: string): string => | |
| 68 | join(app.config.dataDir, "children", bodyId); | |
| 69 | const ledgerPath = (app: App, name: string): string => | |
| 70 | join(app.config.dataDir, "spawn", `${name}.json`); | |
| 71 | ||
| 72 | async function readLedger(app: App, name: string): Promise<Ledger | undefined> { | |
| 73 | const file = Bun.file(ledgerPath(app, name)); | |
| 74 | if (!(await file.exists())) return undefined; | |
| 75 | try { | |
| 76 | return (await file.json()) as Ledger; | |
| 77 | } catch { | |
| 78 | return undefined; | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | async function writeLedger(app: App, ledger: Ledger): Promise<void> { | |
| 83 | await mkdir(join(app.config.dataDir, "spawn"), { recursive: true }); | |
| 84 | const path = ledgerPath(app, ledger.name); | |
| 85 | await Bun.write(`${path}.tmp`, JSON.stringify(ledger)); | |
| 86 | await rename(`${path}.tmp`, path); | |
| 87 | } | |
| 88 | ||
| 89 | export async function spawnForge( | |
| 90 | app: App, | |
| 91 | actor: User | undefined, | |
| 92 | input: SpawnInput, | |
| 93 | source: SpawnSource, | |
| 94 | deps: SpawnDeps, | |
| 95 | ): Promise<Result<Spawned, SpawnError>> { | |
| 96 | if (crossOrigin(deps.request, deps.url, app.log)) return err({ kind: "cross_origin" }); | |
| 97 | const user = requireUser(actor); | |
| 98 | if (!user.ok) return err({ kind: "unauthorized" }); | |
| 99 | if (!user.value.isAdmin) return err({ kind: "forbidden" }); | |
| 100 | const owner = user.value; | |
| 101 | ||
| 102 | // The dry-run is not trusted: the plan is made again, here. | |
| 103 | const planned = planSpawn(input, source); | |
| 104 | if (!planned.ok) return err({ kind: "invalid", ...planned.error }); | |
| 105 | const plan = planned.value; | |
| 106 | ||
| 107 | const names = new Set<string>(); | |
| 108 | for (const a of plan.apps) { | |
| 109 | const name = a.ref.split("/")[1]!; | |
| 110 | if (names.has(name.toLowerCase())) | |
| 111 | return err({ kind: "invalid", field: "apps", reason: `two apps are named ${name}` }); | |
| 112 | names.add(name.toLowerCase()); | |
| 113 | const [o, n] = a.ref.split("/") as [string, string]; | |
| 114 | const repo = getRepo(app.db, o, n); | |
| 115 | if (!repo || !canRead(app.db, repo, owner)) | |
| 116 | return err({ kind: "invalid", field: "apps", reason: `${a.ref} is not readable by you` }); | |
| 117 | } | |
| 118 | for (const b of plan.briefs) { | |
| 119 | if (b.text.length > BRIEF_MAX) | |
| 120 | return err({ kind: "invalid", field: "briefs", reason: `a brief is at most ${BRIEF_MAX} characters` }); | |
| 121 | } | |
| 122 | ||
| 123 | if (inFlight.has(plan.name)) return err({ kind: "exists", message: "that child is being made" }); | |
| 124 | inFlight.add(plan.name); | |
| 125 | try { | |
| 126 | return await make(app, owner, plan, deps); | |
| 127 | } finally { | |
| 128 | inFlight.delete(plan.name); | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | async function make( | |
| 133 | app: App, | |
| 134 | owner: User, | |
| 135 | plan: SpawnPlan, | |
| 136 | deps: SpawnDeps, | |
| 137 | ): Promise<Result<Spawned, SpawnError>> { | |
| 138 | const planHash = sha256(JSON.stringify(plan)); | |
| 139 | let ledger = await readLedger(app, plan.name); | |
| 140 | if (ledger) { | |
| 141 | if (ledger.planHash !== planHash) | |
| 142 | return err({ kind: "exists", message: `a different child named ${plan.name} already exists` }); | |
| 143 | if (ledger.done) | |
| 144 | return ok({ name: plan.name, org: plan.org, url: ledger.url, bodyId: ledger.bodyId, existed: true }); | |
| 145 | } else { | |
| 146 | const placed = await deps.placement.place(plan); | |
| 147 | if (!placed.ok) return err({ kind: "refused", ...placed.error }); | |
| 148 | if (!SAFE_ID.test(placed.value.bodyId)) { | |
| 149 | await deps.placement.retire(placed.value.bodyId); | |
| 150 | return fail("placement returned an unusable body id"); | |
| 151 | } | |
| 152 | ledger = { name: plan.name, bodyId: placed.value.bodyId, url: placed.value.url, planHash, done: false }; | |
| 153 | await writeLedger(app, ledger); | |
| 154 | } | |
| 155 | ||
| 156 | const copied = await copyInto(app, owner, plan, ledger); | |
| 157 | if (!copied.ok) return fail(copied.error); | |
| 158 | ||
| 159 | ledger.done = true; | |
| 160 | await writeLedger(app, ledger); | |
| 161 | app.bus.append({ | |
| 162 | kind: "forge.spawned", | |
| 163 | actorId: owner.id, | |
| 164 | payload: { name: plan.name, org: plan.org, url: ledger.url, apps: plan.apps.map((a) => a.ref) }, | |
| 165 | }); | |
| 166 | return ok({ name: plan.name, org: plan.org, url: ledger.url, bodyId: ledger.bodyId, existed: false }); | |
| 167 | } | |
| 168 | ||
| 169 | /** Every step skips what is already there, so a resumed child is finished, not doubled. */ | |
| 170 | async function copyInto( | |
| 171 | app: App, | |
| 172 | owner: User, | |
| 173 | plan: SpawnPlan, | |
| 174 | ledger: Ledger, | |
| 175 | ): Promise<Result<void, string>> { | |
| 176 | const dir = childDirOf(app, ledger.bodyId); | |
| 177 | await mkdir(dir, { recursive: true }); | |
| 178 | const child = createApp({ dataDir: dir }); | |
| 179 | try { | |
| 180 | // The founder is a name only: no password, no email worth mailing. The | |
| 181 | // child's first boot hands the account to a person (#422). | |
| 182 | const founder = | |
| 183 | getUserByName(child.db, owner.username) ?? | |
| 184 | (await (async () => { | |
| 185 | const made = await createUser(child.db, { | |
| 186 | username: owner.username, | |
| 187 | email: `${owner.username}@spawn.forge.invalid`, | |
| 188 | }); | |
| 189 | return made.ok ? made.value : undefined; | |
| 190 | })()); | |
| 191 | if (!founder) return err("could not make the child's founder"); | |
| 192 | ||
| 193 | let org = getOrg(child.db, plan.org); | |
| 194 | if (!org) { | |
| 195 | const made = createOrg(child.db, plan.org, founder.id); | |
| 196 | if (!made.ok) return err(`could not make org ${plan.org}: ${made.error}`); | |
| 197 | org = made.value; | |
| 198 | } | |
| 199 | ||
| 200 | for (const a of plan.apps) { | |
| 201 | const [o, n] = a.ref.split("/") as [string, string]; | |
| 202 | const parent = getRepo(app.db, o, n); | |
| 203 | if (!parent) return err(`${a.ref} is gone`); | |
| 204 | let repo = getRepo(child.db, org.username, n); | |
| 205 | if (!repo) { | |
| 206 | const made = await createRepo(child.db, child.reposRoot, { | |
| 207 | ownerId: org.id, | |
| 208 | name: n, | |
| 209 | visibility: parent.visibility, | |
| 210 | description: parent.description, | |
| 211 | defaultBranch: parent.defaultBranch, | |
| 212 | }); | |
| 213 | if (!made.ok) return err(`could not make ${a.ref}: ${made.error}`); | |
| 214 | repo = made.value; | |
| 215 | } | |
| 216 | // Branches and tags with every commit behind them; re-running only tops up. | |
| 217 | const fetched = await runGit( | |
| 218 | [ | |
| 219 | "fetch", | |
| 220 | "--no-write-fetch-head", | |
| 221 | repoDir(app.reposRoot, o, n), | |
| 222 | "+refs/heads/*:refs/heads/*", | |
| 223 | "+refs/tags/*:refs/tags/*", | |
| 224 | ], | |
| 225 | { cwd: repoDir(child.reposRoot, org.username, n), timeoutMs: 600_000 }, | |
| 226 | ); | |
| 227 | if (!fetched.ok) return err(`could not copy ${a.ref}: ${fetched.error.stderr}`); | |
| 228 | } | |
| 229 | ||
| 230 | for (const b of plan.briefs) { | |
| 231 | const existing = getAgentByName(child.db, b.agent); | |
| 232 | if (existing) { | |
| 233 | updateAgent(child.db, existing.user.id, { brief: b.text }); | |
| 234 | continue; | |
| 235 | } | |
| 236 | const made = await createUser(child.db, { | |
| 237 | username: b.agent, | |
| 238 | email: `${b.agent}@agents.spawn.forge.invalid`, | |
| 239 | agent: true, | |
| 240 | }); | |
| 241 | if (!made.ok) return err(`could not make crew member ${b.agent}: ${made.error}`); | |
| 242 | createAgentRow(child.db, { userId: made.value.id, brief: b.text, createdBy: founder.id }); | |
| 243 | } | |
| 244 | ||
| 245 | const upstream = app.config.url ?? `http://${app.config.host ?? "localhost"}`; | |
| 246 | await Bun.write( | |
| 247 | join(dir, "parent.json"), | |
| 248 | JSON.stringify({ upstream, name: plan.name, org: plan.org }), | |
| 249 | ); | |
| 250 | return ok(); | |
| 251 | } finally { | |
| 252 | child.close(); | |
| 253 | } | |
| 254 | } |
test/spawn.test.ts added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, readdir, rename, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { childDirOf, spawnForge, type SpawnDeps } from "../src/actions/spawn.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { runGit } from "../src/git/env.ts"; | |
| 10 | import { repoDir } from "../src/git/repos.ts"; | |
| 11 | import { commitFiles } from "../src/git/write.ts"; | |
| 12 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 13 | import type { SpawnSource } from "../src/spawn/plan.ts"; | |
| 14 | import { Database } from "bun:sqlite"; | |
| 15 | ||
| 16 | const SECRET = "sk-live-SECRET-value"; | |
| 17 | const author = { name: "a", email: "[email protected]" }; | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: App; | |
| 21 | let alice: User; | |
| 22 | let deps: SpawnDeps; | |
| 23 | let source: SpawnSource; | |
| 24 | const input = { name: "kid", org: "kids", apps: ["alice/notes"], carryBriefs: true, carrySubscriptions: false }; | |
| 25 | ||
| 26 | const post = (origin?: string) => | |
| 27 | new Request("https://forge.test/spawn", { method: "POST", headers: origin ? { origin } : {} }); | |
| 28 | ||
| 29 | beforeEach(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-spawn-")); | |
| 31 | app = createApp({ dataDir: dir, url: "https://forge.test" }); | |
| 32 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", isAdmin: true }); | |
| 33 | if (!made.ok) throw new Error("user"); | |
| 34 | alice = made.value; | |
| 35 | const repo = await createRepo(app, alice, { name: "notes" }); | |
| 36 | if (!repo.ok) throw new Error("repo"); | |
| 37 | const d = repoDir(app.reposRoot, "alice", "notes"); | |
| 38 | for (const n of ["one", "two"]) { | |
| 39 | const c = await commitFiles(d, { branch: "main", message: `add ${n}`, files: [{ path: `${n}.txt`, content: n }], author }); | |
| 40 | if (!c.ok) throw new Error("commit"); | |
| 41 | } | |
| 42 | app.db.run("UPDATE user SET password_hash = ?1 WHERE id = ?2", [SECRET, alice.id]); | |
| 43 | source = { | |
| 44 | catalog: [{ ref: "alice/notes", readable: true, bytes: 10, forgeJsonBytes: 0 }], | |
| 45 | briefs: [{ agent: "scout", text: "ship small" }], | |
| 46 | subscriptions: [], | |
| 47 | }; | |
| 48 | deps = { | |
| 49 | placement: hostedPlacement({ host: "forge.test" }), | |
| 50 | request: post("https://forge.test"), | |
| 51 | url: new URL("https://forge.test/spawn"), | |
| 52 | }; | |
| 53 | }); | |
| 54 | ||
| 55 | afterEach(async () => { | |
| 56 | app.close(); | |
| 57 | await rm(dir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | const spawn = (patch: object = {}, d = deps, actor: User | undefined = alice) => | |
| 61 | spawnForge(app, actor, { ...input, ...patch }, source, d); | |
| 62 | ||
| 63 | async function allText(root: string): Promise<string> { | |
| 64 | let out = ""; | |
| 65 | for (const e of await readdir(root, { recursive: true, withFileTypes: true })) { | |
| 66 | if (e.isFile()) out += await Bun.file(join(e.parentPath, e.name)).text().catch(() => ""); | |
| 67 | } | |
| 68 | return out; | |
| 69 | } | |
| 70 | ||
| 71 | describe("spawnForge", () => { | |
| 72 | test("apps arrive with their history, briefs arrive, the parent is named", async () => { | |
| 73 | const r = await spawn(); | |
| 74 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 75 | const child = childDirOf(app, r.value.bodyId); | |
| 76 | const log = await runGit(["log", "--format=%s"], { cwd: repoDir(join(child, "repos"), "kids", "notes") }); | |
| 77 | expect(log).toEqual({ ok: true, value: "add two\nadd one" }); | |
| 78 | const db = new Database(join(child, "forge.db"), { readonly: true }); | |
| 79 | expect(db.query("SELECT brief FROM agent").all()).toEqual([{ brief: "ship small" }]); | |
| 80 | db.close(); | |
| 81 | expect(JSON.parse(await Bun.file(join(child, "parent.json")).text())).toEqual({ | |
| 82 | upstream: "https://forge.test", | |
| 83 | name: "kid", | |
| 84 | org: "kids", | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("no secret arrives", async () => { | |
| 89 | const r = await spawn(); | |
| 90 | if (!r.ok) throw new Error(); | |
| 91 | const child = childDirOf(app, r.value.bodyId); | |
| 92 | expect(await allText(child)).not.toContain(SECRET); | |
| 93 | const db = new Database(join(child, "forge.db"), { readonly: true }); | |
| 94 | expect(db.query("SELECT COUNT(*) AS n FROM token").get()).toEqual({ n: 0 }); | |
| 95 | db.close(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a second call does not duplicate, and says one event", async () => { | |
| 99 | const a = await spawn(); | |
| 100 | const b = await spawn(); | |
| 101 | if (!a.ok || !b.ok) throw new Error(); | |
| 102 | expect(b.value.bodyId).toBe(a.value.bodyId); | |
| 103 | expect(b.value.existed).toBe(true); | |
| 104 | expect(await readdir(join(dir, "children"))).toEqual([a.value.bodyId]); | |
| 105 | const events = app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'forge.spawned'").get(); | |
| 106 | expect(events).toEqual({ n: 1 }); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a half-made child is resumed, not duplicated", async () => { | |
| 110 | // First attempt dies after placing: the parent's repo vanishes mid-copy. | |
| 111 | const d = repoDir(app.reposRoot, "alice", "notes"); | |
| 112 | await rename(d, `${d}.away`); | |
| 113 | const first = await spawn(); | |
| 114 | expect(first.ok).toBe(false); | |
| 115 | expect(await readdir(join(dir, "children"))).toHaveLength(1); | |
| 116 | await rename(`${d}.away`, d); | |
| 117 | const second = await spawn(); | |
| 118 | if (!second.ok) throw new Error(JSON.stringify(second.error)); | |
| 119 | expect(second.value.existed).toBe(false); | |
| 120 | expect(await readdir(join(dir, "children"))).toEqual([second.value.bodyId]); | |
| 121 | }); | |
| 122 | ||
| 123 | test("refuses an invalid plan again at the action", async () => { | |
| 124 | const r = await spawn({ name: "Bad Name" }); | |
| 125 | expect(r).toMatchObject({ ok: false, error: { kind: "invalid", field: "name" } }); | |
| 126 | const s = await spawn({ apps: ["alice/unknown"] }); | |
| 127 | expect(s).toMatchObject({ ok: false, error: { kind: "invalid", field: "apps" } }); | |
| 128 | }); | |
| 129 | ||
| 130 | test("owner-only, signed in, same-origin", async () => { | |
| 131 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]" }); | |
| 132 | if (!bob.ok) throw new Error(); | |
| 133 | expect(await spawn({}, deps, bob.value)).toMatchObject({ error: { kind: "forbidden" } }); | |
| 134 | expect(await spawnForge(app, undefined, input, source, deps)).toMatchObject({ error: { kind: "unauthorized" } }); | |
| 135 | const cross = { ...deps, request: post("https://evil.test") }; | |
| 136 | expect(await spawn({}, cross)).toMatchObject({ error: { kind: "cross_origin" } }); | |
| 137 | await expect(readdir(join(dir, "children"))).rejects.toThrow(); | |
| 138 | }); | |
| 139 | }); |
new above
offline