Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# Match the local dev / lockfile npm version — node 22's older npm
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden workflow actions and disable credential persistence

Line 12 and Line 13 use mutable action tags (@v4) instead of commit SHAs, and Line 12 keeps default credential persistence. This weakens CI supply-chain guarantees and increases token exposure risk.

Suggested patch
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@<FULL_COMMIT_SHA>
+        with:
+          persist-credentials: false
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@<FULL_COMMIT_SHA>
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 12-12: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 12-12: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 13-13: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 12 - 15, The GitHub Actions workflow
uses mutable version tags (`@v4`) for both actions/checkout and actions/setup-node
actions instead of pinning to specific commit SHAs, which weakens supply-chain
security. Additionally, the actions/checkout action retains default credential
persistence which increases token exposure risk. Replace the `@v4` mutable tags
with specific commit SHAs for both the actions/checkout and actions/setup-node
actions, and add a token persistence configuration to the actions/checkout
action to explicitly disable credential persistence after checkout completes.

Source: Linters/SAST tools

# resolves the SWC plugin's transitive @swc/helpers differently and
# rejects the lockfile under `npm ci`.
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ tsconfig.tsbuildinfo
# debug
npm-debug.log*

# test artifacts
coverage/
playwright-report/
test-results/
.playwright/

# vercel
.vercel
node_modules
Expand Down
12 changes: 12 additions & 0 deletions components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Badge } from "@/components/ui/badge";

describe("Badge", () => {
it("renders its children", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions components/wizard/legal-disclaimer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LegalDisclaimer } from "@/components/wizard/legal-disclaimer";

describe("LegalDisclaimer", () => {
it("always shows the not-legal-advice notice", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});

it("shows the last-verified date for a verified state", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/last verified/i);
});

it("flags a machine-generated state as not independently verified", () => {
render(<LegalDisclaimer stateCode="AL" />);
const note = screen.getByRole("note");
expect(note).toHaveTextContent(/not independently verified/i);
expect(note).not.toHaveTextContent(/last verified/i);
});

it("renders a generic notice when no state is known", () => {
render(<LegalDisclaimer stateCode={null} />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});
});
86 changes: 86 additions & 0 deletions components/wizard/legal-disclaimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { ShieldAlert } from "lucide-react";
import { getStateVerification } from "@/content/state-registry";

interface LegalDisclaimerProps {
/** Two-letter state code from the user's profile, if known. */
stateCode?: string | null;
}

/** Format an ISO date (YYYY-MM-DD) without UTC timezone drift. */
function formatVerifiedDate(iso?: string): string {
if (!iso) return "";
const [y, m, d] = iso.split("-").map(Number);
if (!y || !m || !d) return iso;
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}

/**
* Persistent, non-dismissable legal notice shown on state-facing content.
* Surfaces whether the state's regulatory data was human-verified (with the
* date) or is machine-generated and unverified.
*/
export function LegalDisclaimer({ stateCode }: LegalDisclaimerProps) {
const v = stateCode ? getStateVerification(stateCode) : null;
const agency = v?.stateName ?? "your state";

return (
<div
role="note"
aria-label="Legal disclaimer"
className="flex items-start gap-2.5 rounded-lg border border-neon-amber/25 bg-neon-amber/5 px-3.5 py-2.5 text-xs leading-relaxed"
>
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-neon-amber" />
<div className="space-y-1 text-text-secondary">
<p>
<span className="font-semibold text-neon-amber">Not legal advice.</span>{" "}
Fees, rules, and links change — verify with the official {agency}{" "}
agency before acting.
</p>
{v &&
(v.verified ? (
<p className="text-text-muted">
Last verified {formatVerifiedDate(v.lastVerified)}.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Source
</a>
</>
)}
</p>
) : (
<p className="text-text-muted">
Auto-generated for {v.stateName} — not independently verified.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Confirm with the state agency
</a>
</>
)}
</p>
))}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions components/wizard/step-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Checklist } from "./checklist";
import { CostCard } from "./cost-card";
import { ResourceLink } from "./resource-link";
import { CoachTip } from "./coach-tip";
import { LegalDisclaimer } from "./legal-disclaimer";
import {
ChevronLeft,
ChevronRight,
Expand Down Expand Up @@ -55,6 +56,7 @@ export function StepContent({
<div className="flex-1 overflow-y-auto scrollbar-thin bg-cyber-black">
<div className="max-w-3xl mx-auto p-6 md:p-8 space-y-8">
{headerExtra}
<LegalDisclaimer stateCode={profile?.state} />
{/* Header */}
<div className="space-y-3 animate-fade-in-up">
<div className="text-xs text-neon-cyan font-mono tracking-widest uppercase">
Expand Down
133 changes: 133 additions & 0 deletions content/phases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import {
getPhaseContent,
getStepByIds,
getNextStep,
getPrevStep,
PHASE_DEFINITIONS,
type StateCode,
} from "@/content/phases";
import { STATE_REGISTRY } from "@/content/state-registry";
import type { Phase, Step } from "@/lib/types/content";

const PHASE_IDS = PHASE_DEFINITIONS.map((p) => p.id);
const STATE_CODES = Object.keys(STATE_REGISTRY) as StateCode[];

function assertValidStep(step: Step) {
expect(typeof step.id).toBe("string");
expect(step.id.length).toBeGreaterThan(0);
expect(typeof step.title).toBe("string");
expect(step.title.length).toBeGreaterThan(0);
expect(Array.isArray(step.checklist)).toBe(true);
expect(Array.isArray(step.resources)).toBe(true);
expect(Array.isArray(step.tips)).toBe(true);
expect(Array.isArray(step.warnings)).toBe(true);
expect(step.estimatedCost).toBeTypeOf("object");
expect(step.estimatedCost.min).toBeTypeOf("number");
expect(step.estimatedCost.max).toBeTypeOf("number");
expect(step.aiContext).toBeTypeOf("string");
}

function assertValidPhase(phase: Phase) {
expect(phase.id).toBeTypeOf("string");
expect(phase.title.length).toBeGreaterThan(0);
expect(Array.isArray(phase.steps)).toBe(true);
expect(phase.steps.length).toBeGreaterThan(0);
for (const step of phase.steps) assertValidStep(step);
}

describe("getPhaseContent — content contract", () => {
it("covers exactly the 50 states", () => {
expect(STATE_CODES.length).toBe(50);
});

it("resolves valid, non-empty content for every state × phase", () => {
for (const state of STATE_CODES) {
for (const phaseId of PHASE_IDS) {
assertValidPhase(getPhaseContent(phaseId, state));
}
}
});

it("serves generated (not Washington) content for a non-WA state", () => {
const wa = getPhaseContent("business-formation", "WA");
const tx = getPhaseContent("business-formation", "TX");
expect(JSON.stringify(tx)).not.toBe(JSON.stringify(wa));
});

it("returns a labeled 'unavailable' phase — never Washington's — for an unknown code", () => {
const unknown = getPhaseContent("business-formation", "ZZ" as StateCode);
const wa = getPhaseContent("business-formation", "WA");
expect(unknown.steps[0].id).toBe("data-unavailable");
expect(unknown.steps[0].title).toContain("ZZ");
expect(JSON.stringify(unknown)).not.toBe(JSON.stringify(wa));
});

it("returns a well-formed (non-empty, valid) phase even for an unknown code", () => {
assertValidPhase(getPhaseContent("contractor-licensing", "ZZ" as StateCode));
});

it("lets the chat-route step lookup find a phase's first step (parity)", () => {
const state: StateCode = "CO";
const phase = getPhaseContent("contractor-licensing", state);
const firstId = phase.steps[0].id;
const found = getStepByIds("contractor-licensing", firstId, state);
expect(found).not.toBeNull();
expect(found?.id).toBe(firstId);
});
});

describe("getPhaseContent — unavailable + static phases", () => {
it("returns a valid merged phase carrying the unavailable step for an unknown code", () => {
const phase = getPhaseContent("surety-bonding", "ZZ" as StateCode);
assertValidPhase(phase);
expect(phase.steps.some((s) => s.id === "data-unavailable")).toBe(true);
});

it("returns non-empty legal-federal content for any state", () => {
assertValidPhase(getPhaseContent("legal-federal", "TX"));
});

it("returns a defined, empty-step phase for an unknown phase id (no throw)", () => {
const phase = getPhaseContent("does-not-exist", "TX");
expect(phase).toBeDefined();
expect(phase.steps).toEqual([]);
});
});

describe("wizard navigation (getNextStep / getPrevStep)", () => {
const state: StateCode = "TX";

it("advances to the next step within a phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const steps = getPhaseContent(firstPhase, state).steps;
expect(steps.length).toBeGreaterThanOrEqual(2);
expect(getNextStep(firstPhase, steps[0].id, state)).toEqual({
phaseId: firstPhase,
stepId: steps[1].id,
});
});

it("crosses to the next phase from the last step of a phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const secondPhase = PHASE_DEFINITIONS[1].id;
const steps = getPhaseContent(firstPhase, state).steps;
const next = getNextStep(firstPhase, steps[steps.length - 1].id, state);
expect(next?.phaseId).toBe(secondPhase);
expect(next?.stepId).toBe(getPhaseContent(secondPhase, state).steps[0].id);
});

it("returns null past the last step of the last phase", () => {
const lastPhase = PHASE_DEFINITIONS[PHASE_DEFINITIONS.length - 1].id;
const steps = getPhaseContent(lastPhase, state).steps;
expect(getNextStep(lastPhase, steps[steps.length - 1].id, state)).toBeNull();
});

it("returns null before the first step of the first phase", () => {
const firstPhase = PHASE_DEFINITIONS[0].id;
const firstId = getPhaseContent(firstPhase, state).steps[0].id;
expect(getPrevStep(firstPhase, firstId, state)).toBeNull();
});
});
Loading
Loading