Skip to content

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.

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.

apps/admin/src may import @drawnagency/* only as import type:

// Correct — type import is erased by esbuild, no dist needed
import type { Audience } from "@drawnagency/primitives";
// Wrong — runtime import fails when dist/ is absent
import { 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.

Two gates in CI catch violations:

Terminal window
node scripts/check-admin-isolation.mjs

scripts/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 is a violation.

This runs in CI before build:packages, so it does not depend on the packages being built.

Terminal window
pnpm --filter portal-admin build

Also 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.

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:

apps/admin/src/lib/slugify.ts
// 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.