Backend & APIs No. 080

Deno Runtime skill for Claude Code

Scoped permissions instead of -A everywhere. Claude sets up Deno projects so every task runs with the narrowest permissions that work: scoped hosts, paths, env vars and binaries recorded as permission sets in deno.json, plus deny rules for metadata endpoints. Dependencies come from JSR and npm with a committed lockfile, tests keep sanitizers on, and shipping goes through deno compile, a pinned container, or Deno Deploy.

  • namedeno-runtime
  • size215 lines · ≈2,200 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and runs Deno 2 projects with least-privilege permissions - scoped allow and deny flags, permission sets in deno.json, deno.json tasks and import maps, npm and JSR packages, Node compatibility settings, Deno.serve and deno serve, Deno.test with steps and sanitizers, deno compile, JSR publishing, and Deno Deploy. Use when starting or migrating a project to Deno, replacing -A with scoped permissions, adding npm packages to a Deno app, writing Deno tests, or deploying to Deno Deploy.

Try asking

  • Replace -A in our deno.json tasks with the minimum permissions each needs
  • Port this small Node script to Deno 2 and keep using the npm packages
  • Our Deno tests fail with leaking resources errors. Fix them properly

Use it when

  • Starting a Deno 2 project or migrating a Node script to Deno
  • Tightening permissions on tasks that run with -A
  • Adding npm or JSR dependencies to a Deno app
  • Writing Deno tests, compiling binaries, or deploying to Deno Deploy

Skip it when

  • The project runs on Bun or uses Bun-specific APIs — use bun-runtime
  • The target is Cloudflare Workers bindings and limits — use cloudflare-workers

How to check the result

deno install --frozen, deno check, lint, fmt --check and deno test -P pass, no task contains -A, and removing one permission makes the app fail with NotCapable.

The common mistake it prevents

Granting --allow-run or unscoped --allow-env to make one call work, which quietly gives the process the same power as -A.

~/.claude/skills/deno-runtime/SKILL.md

--- name: deno-runtime description: Builds and runs Deno 2 projects with least-privilege permissions - scoped allow and deny flags, permission sets in deno.json, deno.json… license: MIT ---

Done well means: no task runs with -A, each permission is scoped to the paths, hosts, env vars and binaries actually used, dependencies are pinned in deno.json with a committed deno.lock, and deno check, deno lint, deno fmt --check and deno test all pass in CI.

Workflow

- [ ] 1. Inventory the Deno version, config and dependency sources
- [ ] 2. Define tasks and imports in deno.json; commit deno.lock
- [ ] 3. Derive least-privilege permissions and encode them in config
- [ ] 4. Write the server or script on web-standard APIs
- [ ] 5. Test with Deno.test, sanitizers on, permissions scoped
- [ ] 6. Ship via deno compile, a container, or Deno Deploy

1. Inventory

deno --version
cat deno.json deno.jsonc package.json 2>/dev/null
deno info main.ts | head -40        # dependency graph: jsr:, npm:, https:, local
grep -rn "deno.land/std" --include=*.ts . | head   # legacy std imports to migrate

Record: Deno 2 minor (2.5 added permission sets in config, 2.6 more granular permissions and deno audit, 2.7 stabilized the Temporal API, 2.9 shipped June 2026), whether the project is package.json-driven or deno.json-driven, and any https:// imports. Migrate deno.land/std URLs to jsr:@std/* when touching those files.

2. deno.json

{
  "tasks": {
    "dev": "deno run --watch -P main.ts",
    "start": "deno run -P main.ts",
    "test": "deno test -P",
    "check": "deno check . && deno lint && deno fmt --check"
  },
  "imports": {
    "@std/assert": "jsr:@std/assert@^1.0.0",
    "@std/http": "jsr:@std/http@^1.0.0",
    "zod": "npm:zod@^4.0.0"
  },
  "permissions": {
    "default": {
      "net": ["0.0.0.0:8000", "api.stripe.com:443", "db.internal:5432"],
      "env": ["DATABASE_URL", "STRIPE_KEY", "PORT"],
      "read": ["./static"]
    }
  },
  "test": {
    "permissions": { "read": ["./fixtures"], "env": ["DATABASE_URL"] }
  },
  "lint": { "rules": { "tags": ["recommended"] } },
  "fmt": { "lineWidth": 100 }
}
deno add jsr:@std/path npm:pg       # writes to imports with a caret range
deno remove npm:pg
deno install                        # install everything (also reads package.json)
deno outdated                       # then update ranges deliberately
deno install --frozen               # CI: fail if deno.lock would change
  • -P with no value applies the default permission set; -P=name or --permission-set=name applies a named one (Deno 2.5+). Once test.permissions exists, deno test must be run with -P or explicit flags.
  • On Deno older than 2.5, write the scoped flags into each task string instead.
  • nodeModulesDir: leave unset (global cache) for pure Deno apps; set "auto" when a framework or tool expects a real node_modules (Vite, Next-style tooling, packages that resolve files from disk), "manual" when another package manager owns it.
  • npm lifecycle scripts do not run by default; allow specific ones with deno install --allow-scripts=npm:<pkg> only after reviewing what they do.

3. Least-privilege permissions

NeedFlag (scoped)Avoid
Listen on a port--allow-net=0.0.0.0:8000--allow-net
Call specific APIs--allow-net=api.example.com:443wildcard hosts
Read config/static files--allow-read=./static,./config--allow-read on /
Write output--allow-write=./out--allow-write
Env vars--allow-env=DATABASE_URL,PORT--allow-env
Spawn a binary--allow-run=git--allow-run (equals full access)
FFI / native addons--allow-ffi=./lib/libfoo.soblanket --allow-ffi
Block something inside a broad allow--deny-net=169.254.169.254relying on review alone

Derivation loop:

  1. Run with no permissions in a terminal; Deno prompts for each access. Note every request, or set DENO_AUDIT_PERMISSIONS=./perm-audit.jsonl (2.5+) to log checks.
  2. Encode the minimal set in deno.json permissions or task flags.
  3. Add --no-prompt (non-interactive contexts already fail instead of prompting) so a missing permission fails loudly rather than hanging.
  4. Treat --allow-run, --allow-ffi, unscoped --allow-write and --allow-env as equivalent to -A; justify each in a comment or the PR.
  5. Deny cloud metadata endpoints (169.254.169.254) when net access is broad.

4. Server code

// main.ts
import { z } from "zod";

const Order = z.object({ sku: z.string().min(1), qty: z.number().int().min(1).max(99) });

export default {
  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    if (req.method === "GET" && url.pathname === "/healthz") return new Response("ok");
    if (req.method === "POST" && url.pathname === "/orders") {
      const parsed = Order.safeParse(await req.json().catch(() => null));
      if (!parsed.success) return Response.json(z.flattenError(parsed.error), { status: 422 });
      return Response.json({ id: crypto.randomUUID(), ...parsed.data }, { status: 201 });
    }
    return new Response("not found", { status: 404 });
  },
} satisfies Deno.ServeDefaultExport;
deno serve -P --port 8000 main.ts           # uses the default export
deno serve -P --parallel main.ts            # one worker per core

Or call Deno.serve({ port: 8000, signal }, handler) directly; pass an AbortSignal and abort it on SIGTERM (Deno.addSignalListener("SIGTERM", ...)) to drain cleanly. Hono, Oak and Fresh run on Deno when a framework is wanted.

Node compatibility: node: built-ins and most npm packages work. CommonJS loads from .cjs files or packages with "type": "commonjs"; require is not available in ESM .ts files (use createRequire from node:module if needed). Globals like process and Buffer exist in npm packages; in your own code import them from node:process and node:buffer for clarity.

5. Tests

import { assertEquals, assertRejects } from "@std/assert";
import handler from "./main.ts";

Deno.test("POST /orders validates qty", async (t) => {
  await t.step("rejects qty 0", async () => {
    const res = await handler.fetch(new Request("http://x/orders", {
      method: "POST",
      body: JSON.stringify({ sku: "A1", qty: 0 }),
    }));
    assertEquals(res.status, 422);
    await res.body?.cancel();        // unconsumed bodies trip the resource sanitizer
  });
});
deno test -P --coverage=cov
deno coverage cov --lcov > cov.lcov
deno test -P --filter "orders" --fail-fast
  • Sanitizers fail tests that leak timers, open files, or unconsumed response bodies. Fix the leak; disable sanitizeOps/sanitizeResources per test only for a documented third-party leak.
  • @std/testing provides bdd (describe/it), mock (stub, spy) and time (FakeTime). Deno 2.5+ also has Deno.test.beforeAll/beforeEach/afterAll/ afterEach hooks.
  • Tests get only the permissions you pass; a test that needs the network must say so.

6. Ship

TargetCommand / approach
Single binarydeno compile -P --output app main.ts; cross-compile with --target x86_64-unknown-linux-gnu. Permissions are baked in at compile time
ContainerFROM denoland/deno:<exact-version>, RUN deno install --frozen, run deno run -P main.ts as the deno user
Deno Deploy (GA since Feb 2026)Connect the GitHub repo or use the deploy CLI described in current Deploy docs; set env vars in the dashboard; no local filesystem writes
JSR packagedeno publish --dry-run, then deno publish; requires name, version, exports in deno.json

Deploy tooling changed during 2025 (the classic deployctl flow was replaced by the new Deploy platform). Check the current docs before writing CI steps instead of copying older examples.

Gotchas

  • deno.lock must be committed; without it, caret ranges float between machines.
  • --allow-net without a port allows every port on that host.
  • Remote https:// imports need --allow-import for hosts outside Deno’s default allowlist; prefer JSR or npm specifiers over raw URLs.
  • deno check is separate from deno run; type errors do not stop deno run unless --check is passed.
  • Deno.openKv() and other unstable APIs need the matching --unstable-* flag or "unstable": ["kv"] in deno.json.

Verification

deno install --frozen
deno task check                  # deno check . && deno lint && deno fmt --check
deno test -P
grep -n '"-A"\|--allow-all\| -A ' deno.json   # must return nothing

Run the production command once with a permission removed (e.g. drop one env var from the set) and confirm it fails with a clear NotCapable error rather than misbehaving; that proves the permission boundary is real.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc