apps/admin isolation
apps/admin is the internal provisioner — a separate Astro app that creates and configures client sites. It deploys to its own Netlify site with its own astro build. That build does not run build:packages.
The problem
Section titled “The problem”Because apps/admin’s Netlify build runs without building the workspace packages, there is no dist/ directory inside any @drawnagency/* package at deploy time. A runtime (value) import like:
import { slugifyAudienceName } from "@drawnagency/primitives";would fail with a module-not-found error at build time on Netlify — because Node can’t find @drawnagency/primitives/dist/index.js.
The rule
Section titled “The rule”apps/admin/src may import @drawnagency/* only as import type — with one exception, @drawnagency/platform, covered below:
// Correct — type import is erased by esbuild, no dist neededimport type { Audience } from "@drawnagency/primitives";
// Wrong — runtime import fails when dist/ is absentimport { Audience } from "@drawnagency/primitives";
// Also wrong — inline type-only still flagged; hoist it to `import type`import { type Audience } from "@drawnagency/primitives";Type imports are erased by esbuild during astro build and have no runtime presence. import { type X } (inline type-only) is also erased but is flagged by the static checker anyway — hoist it to a top-level import type statement.
The exception: @drawnagency/platform
Section titled “The exception: @drawnagency/platform”@drawnagency/platform (private, never published) is the one package admin may runtime-import. It works because the package has no build step: every entry in its package.json exports points at ./src/*.ts, and apps/admin/astro.config.mjs lists it under vite.ssr.noExternal, so Vite bundles it from source during astro build instead of resolving a dist/ that does not exist. Two conditions keep this safe, and both are gated:
- platform must stay source-exported — the static check (
findDistExports) fails the build if any platform export ever resolves underdist/; - platform must import
@drawnagency/primitivesasimport typeonly, or the bundle would drag primitives’ missingdist/back in (admin would then also neednoExternal: ["@drawnagency/primitives"]).
Import platform subpaths rather than its root barrel from any browser-context file — the barrel aggregates server-only modules. The rule file .claude/rules/admin-isolation.md is the source of record.
Enforcement
Section titled “Enforcement”Two gates in CI catch violations:
Layer 1: static check
Section titled “Layer 1: static check”node scripts/check-admin-isolation.mjsscripts/check-admin-isolation.mjs walks every .ts, .tsx, .astro, .mts, and .cts file under apps/admin/src/. It strips comments (preserving string literals so import specifiers remain visible) then pattern-matches for runtime @drawnagency/* imports: import/export ... from "@drawnagency/...", bare side-effect imports, dynamic import(...), and require(...). Any match other than @drawnagency/platform (allow-listed) is a violation. The same script reads packages/platform/package.json and fails if any export points at dist/.
This runs in CI before build:packages, so it does not depend on the packages being built.
Layer 2: build with dist absent
Section titled “Layer 2: build with dist absent”pnpm --filter portal-admin buildAlso runs before build:packages in CI. With no dist/ present, any runtime import causes an actual build failure — the exact Netlify failure mode. This is the practical proof that the static gate is working.
Duplicating values locally
Section titled “Duplicating values locally”When apps/admin needs a value that exists in @drawnagency/primitives, duplicate it locally rather than importing it. Example: apps/admin/src/lib/slugify.ts provides slugifySubdomain and slugifyAudienceName — functions that are byte-for-byte equivalent to the primitives originals but live entirely in admin:
// Audience slug — kept byte-for-byte equivalent to @drawnagency/primitives'// slugifyAudienceName (schemas/audience.ts). Duplicated here on purpose:// apps/admin must stay free of primitives runtime imports.export function slugifyAudienceName(input: string): string { return input .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "");}If the canonical function in primitives changes, the admin copy must be updated to match. The comment on the admin copy documents this intentional duplication.